들어가며
JWT 로그인은 서버가 "이 사용자는 인증되었다"는 내용을 담은 토큰에 서명해서 발급하고, 클라이언트가 이 토큰을 요청마다 보내면 서버가 서명만 확인해서 사용자를 식별하는 방식입니다. 세션 방식과 달리 서버가 로그인 상태를 저장하지 않아도 된다는 점이 핵심입니다.
실무에서는 토큰을 하나만 쓰지 않고 Access Token과 Refresh Token 두 개를 함께 씁니다. 이 글에서는 로그인 요청 하나가 어떤 토큰이 되는지, 서버가 그 토큰을 어떻게 검증하는지, 만료되면 어떻게 재발급하는지, 로그아웃하면 무엇이 지워지고 무엇이 남는지를 실제 HTTP 요청·응답과 FastAPI 코드로 차례로 따라갑니다.
예제 기준 버전: Python 3.12, FastAPI 0.162, PyJWT 2.7, bcrypt 5.0, redis-py 8.1. 글에 나오는 요청과 응답은 예제 서버를 실제로 실행해서 확인한 결과입니다.
두 토큰의 역할
| 구분 | Access Token | Refresh Token |
|---|---|---|
| 용도 | API 호출 시 사용자 식별 | Access Token 재발급 |
| 수명 | 짧음 (예: 15분) | 김 (예: 14일) |
| 보내는 곳 | 모든 API 요청의 Authorization 헤더 |
재발급·로그아웃 엔드포인트에만 |
| 서버 저장 | 하지 않음 | jti를 Redis에 저장 |
| 검증 방법 | 서명과 만료 시각만 확인 | 서명, 만료 시각, Redis 존재 여부 확인 |
토큰을 둘로 나누는 이유는 서로 충돌하는 두 요구를 동시에 만족시키기 위해서입니다. API 호출은 빈번하므로 매번 저장소를 조회하지 않는 것이 좋고, 그러려면 서버가 토큰을 기억하지 않아야 합니다. 그런데 서버가 기억하지 않는 토큰은 탈취되어도 만료 전까지 막을 방법이 없습니다. 그래서 자주 쓰는 Access Token은 저장하지 않되 수명을 짧게 하고, 드물게 쓰는 Refresh Token은 수명을 길게 하되 서버가 저장해서 통제합니다.
JWT의 구조
로그인에 성공하면 서버는 다음과 같은 문자열을 돌려줍니다. 점(.)으로 구분된 세 부분으로 되어 있습니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3OTQyOTgwMzAsImV4cCI6MTc5NDI5ODkzMH0.p7HmnCKr-86VfJnE7bFIE9nfKvzcGycVocbCciQwQ1U
└────────── Header ──────────┘ └──────────────────────────── Payload ────────────────────────────┘ └──────────── Signature ────────────┘
앞의 두 부분은 JSON을 Base64URL로 인코딩한 것이라 누구나 디코딩해서 읽을 수 있습니다.
// Header: 서명 알고리즘
{"alg": "HS256", "typ": "JWT"}
// Payload: 실제 정보
{"sub": "42", "type": "access", "iat": 1794298030, "exp": 1794298930}
| 클레임 | 의미 | 예제 값 |
|---|---|---|
sub |
토큰의 주인 (사용자 ID) | "42" |
iat |
발급 시각 (Unix timestamp, 초) | 1794298030 |
exp |
만료 시각 | 1794298930 (발급 + 900초 = 15분) |
jti |
토큰 고유 ID (Refresh Token에만 넣음) | "d477afb9..." |
type |
이 예제에서 정한 커스텀 클레임. access인지 refresh인지 구분 | "access" |
세 번째 부분인 Signature는 앞의 두 부분을 서버만 아는 비밀키로 서명한 값입니다.
Signature = HMAC-SHA256( base64url(Header) + "." + base64url(Payload), 비밀키 )
읽을 수는 있지만 고칠 수는 없다
Payload는 암호화된 것이 아니라 인코딩만 된 것이므로 비밀번호나 개인정보를 넣으면 안 됩니다. 대신 내용을 수정할 수는 없습니다. 실제로 위 토큰의 Payload에서 sub를 "42"에서 "1"로 바꾸고 Signature는 그대로 둔 채 서버에 보내 보면 다음과 같이 거절됩니다.
jwt.exceptions.InvalidSignatureError: Signature verification failed
서버가 Header와 바뀐 Payload로 서명을 다시 계산하면 토큰에 붙어 온 Signature와 값이 달라지기 때문입니다. 공격자가 바뀐 Payload에 맞는 서명을 새로 만들려면 비밀키가 필요한데, 비밀키는 서버에만 있습니다.
시나리오로 따라가기: 로그인부터 로그아웃까지
예제 서버는 엔드포인트 네 개로 구성됩니다. 전체 코드는 글 마지막에 있습니다.
| 엔드포인트 | 역할 |
|---|---|
POST /auth/login |
비밀번호 확인 후 두 토큰 발급 |
GET /api/me |
Access Token이 필요한 일반 API |
POST /auth/refresh |
Refresh Token으로 두 토큰 재발급 |
POST /auth/logout |
Refresh Token 폐기 |
장면 0: 회원가입 시점의 준비
서버는 비밀번호를 그대로 저장하지 않고, bcrypt나 argon2 같은 해시 함수로 변환한 값만 저장합니다.
password_hash = bcrypt.hashpw(b"mypassword123", bcrypt.gensalt())
# b'$2b$12$Kx9...' 형태. 원래 비밀번호로 되돌릴 수 없음
해시는 되돌릴 수 없으므로, 로그인할 때는 입력받은 비밀번호를 같은 방식으로 해시해서 저장된 값과 일치하는지만 비교합니다.
장면 1: 로그인 — POST /auth/login
클라이언트가 이메일과 비밀번호를 보냅니다. 비밀번호가 평문으로 전송되므로 HTTPS는 필수입니다.
POST /auth/login
Content-Type: application/json
{"email": "user@example.com", "password": "mypassword123"}
서버는 사용자를 찾고 비밀번호를 확인합니다.
@app.post("/auth/login")
def login(body: LoginRequest, response: Response):
user = USERS.get(body.email)
if user is None or not bcrypt.checkpw(body.password.encode(), user["password_hash"]):
raise HTTPException(401, "invalid email or password")
return issue_tokens(response, user["id"])
이메일이 없는 경우와 비밀번호가 틀린 경우에 같은 에러 메시지를 돌려주는 점에 주목할 만합니다. 실제로 두 경우 모두 401 {"detail": "invalid email or password"}가 나옵니다. 메시지를 구분하면 공격자가 어떤 이메일이 가입되어 있는지 알아낼 수 있습니다.
확인에 성공하면 issue_tokens()가 두 토큰을 만듭니다.
def issue_tokens(response: Response, user_id: str) -> dict:
jti = uuid.uuid4().hex
r.set(f"refresh:{jti}", user_id, ex=REFRESH_TTL) # 이 Refresh Token이 유효하다는 기록
r.sadd(f"user_refresh:{user_id}", jti) # 사용자별 Refresh Token 목록
access = create_token(user_id, "access", ACCESS_TTL)
refresh = create_token(user_id, "refresh", REFRESH_TTL, jti)
response.set_cookie(
"refresh_token", refresh,
httponly=True, secure=True, samesite="strict",
path="/auth", max_age=int(REFRESH_TTL.total_seconds()),
)
return {"access_token": access, "token_type": "bearer"}
실제 응답은 다음과 같습니다. 토큰 문자열은 줄였습니다.
HTTP/1.1 200 OK
set-cookie: refresh_token=eyJ...; HttpOnly; Max-Age=1209600; Path=/auth; SameSite=strict; Secure
content-type: application/json
{"access_token": "eyJ...", "token_type": "bearer"}
Access Token은 응답 본문으로, Refresh Token은 쿠키로 전달됩니다. 이 시점에 Redis에는 키 두 개가 생깁니다.
refresh:d477afb9b6c747d6a90319e03f0cf560 → "42" (TTL 14일)
user_refresh:42 → {"d477afb9b6c747d6a90319e03f0cf560"}
Access Token에 대한 기록은 어디에도 남지 않습니다.
장면 2: API 호출 — GET /api/me
클라이언트는 이후 모든 API 요청에 Access Token을 헤더로 붙입니다.
GET /api/me
Authorization: Bearer eyJ...
FastAPI에서는 의존성 하나로 검증을 처리합니다.
bearer = HTTPBearer()
def decode_token(token: str, expected_type: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError:
raise HTTPException(401, "token expired")
except jwt.InvalidTokenError:
raise HTTPException(401, "invalid token")
if payload.get("type") != expected_type:
raise HTTPException(401, "invalid token type")
return payload
def current_user(cred: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
return decode_token(cred.credentials, "access")["sub"]
@app.get("/api/me")
def me(user_id: str = Depends(current_user)):
return {"user_id": user_id}
jwt.decode() 한 줄 안에서 다음 일이 순서대로 일어납니다.
- 토큰을 점으로 나눠 Header, Payload, Signature를 분리합니다.
- Header의
alg가algorithms에 지정한 값인지 확인합니다. - Header와 Payload로 서명을 다시 계산해서 Signature와 비교합니다. 다르면
InvalidSignatureError입니다. - Payload의
exp를 현재 시각과 비교합니다. 지났으면ExpiredSignatureError입니다.
그다음 type이 "access"인지 확인하고, 통과하면 sub에서 사용자 ID를 꺼냅니다.
이 과정 어디에서도 DB나 Redis를 조회하지 않습니다. 서명 계산은 CPU만으로 끝나므로 빠르고, 서버가 여러 대여도 같은 비밀키만 공유하면 어느 서버에서든 검증할 수 있습니다.
여러 종류의 토큰을 /api/me에 보냈을 때 실제 결과는 다음과 같습니다.
| 보낸 것 | 결과 | 걸린 지점 |
|---|---|---|
| 정상 Access Token | 200 {"user_id": "42"} |
— |
Authorization 헤더 없음 |
401 Not authenticated |
HTTPBearer |
| Payload를 변조한 토큰 | 401 invalid token |
서명 비교 |
| 만료된 Access Token | 401 token expired |
exp 확인 |
| Refresh Token | 401 invalid token type |
type 확인 |
alg: none으로 만든 서명 없는 토큰 |
401 invalid token |
algorithms 확인 |
Refresh Token도 같은 비밀키로 서명되어 있으므로 서명 검증만으로는 Access Token과 구분되지 않습니다. 그래서 type 클레임으로 용도를 확인해야 Refresh Token을 Access Token 자리에 쓰는 것을 막을 수 있습니다.
장면 3: Access Token 만료
발급 후 15분이 지나면 exp 확인에서 걸립니다.
HTTP/1.1 401 Unauthorized
{"detail": "token expired"}
클라이언트는 이 응답을 받아도 사용자를 바로 로그인 화면으로 보내지 않습니다. 대신 Refresh Token으로 새 Access Token을 받아 오는 다음 단계로 넘어갑니다.
장면 4: 재발급 — POST /auth/refresh
POST /auth/refresh
Cookie: refresh_token=eyJ...
요청 본문은 비어 있습니다. 쿠키의 Path=/auth가 요청 경로와 맞으므로 브라우저가 Refresh Token 쿠키를 자동으로 붙여 보냅니다.
@app.post("/auth/refresh")
def refresh(response: Response, refresh_token: str | None = Cookie(default=None)):
if refresh_token is None:
raise HTTPException(401, "no refresh token")
payload = decode_token(refresh_token, "refresh")
user_id, jti = payload["sub"], payload["jti"]
if r.delete(f"refresh:{jti}") == 0:
# 서명은 유효한데 저장소에 없다 → 이미 한 번 쓰인 토큰이 다시 들어왔다
revoke_all(user_id)
raise HTTPException(401, "refresh token reused")
r.srem(f"user_refresh:{user_id}", jti)
return issue_tokens(response, user_id)
순서는 다음과 같습니다.
- Access Token과 같은 방식으로 서명, 만료,
type을 확인합니다. 여기까지는 저장소를 조회하지 않습니다. - Redis에서
refresh:{jti}키를 삭제합니다. 삭제된 개수가 1이면 유효한 토큰이었다는 뜻입니다. - 새 Access Token과 새 Refresh Token을 발급하고, 새
jti를 Redis에 저장합니다.
2번에서 "조회 후 삭제"를 두 번에 나눠 하지 않고 DELETE 한 번의 결과로 판단하는 이유는 동시성 때문입니다. 같은 Refresh Token으로 요청 두 개가 동시에 들어와도 Redis의 DELETE는 원자적이므로 둘 중 하나만 1을 받습니다.
실행해 보면 응답의 Set-Cookie로 새 Refresh Token이 내려오고, Redis의 jti도 바뀝니다.
재발급 전: refresh:d477afb9b6c747d6a90319e03f0cf560
재발급 후: refresh:543da375613f4e78b6ca1c6496550ecc
이처럼 재발급할 때마다 Refresh Token도 새것으로 바꾸고 이전 것은 폐기하는 방식을 Refresh Token Rotation이라고 합니다. 각 Refresh Token은 한 번만 쓸 수 있게 됩니다.
클라이언트는 새로 받은 Access Token으로 장면 3에서 실패했던 요청을 다시 보냅니다. 사용자 입장에서는 아무 일도 없었던 것처럼 보입니다.
장면 5: 이미 쓴 Refresh Token이 다시 들어오면
Rotation의 효과는 Refresh Token이 탈취되었을 때 드러납니다. 장면 4에서 폐기된 첫 번째 Refresh Token을 누군가 복사해 두었다가 보낸다고 가정합니다.
POST /auth/refresh
Cookie: refresh_token=eyJ...(폐기된 첫 번째 토큰)
HTTP/1.1 401 Unauthorized
{"detail": "refresh token reused"}
이 토큰은 서명도 유효하고 만료되지도 않았지만, Redis에 jti가 없습니다. 서버가 직접 발급했는데 이미 사용된 토큰이 다시 들어왔다는 뜻이므로, 서버는 탈취를 의심하고 그 사용자의 Refresh Token을 전부 폐기합니다.
def revoke_all(user_id: str) -> None:
key = f"user_refresh:{user_id}"
for jti in r.smembers(key):
r.delete(f"refresh:{jti}")
r.delete(key)
실제로 이 요청 직후 Redis의 키는 모두 사라지고, 정상 사용자가 갖고 있던 최신 Refresh Token으로 재발급을 요청해도 401 refresh token reused가 나옵니다. 정상 사용자도 다시 로그인해야 하는데, 이것이 의도된 동작입니다. 서버 입장에서는 둘 중 누가 공격자인지 구분할 수 없으므로, 둘 다 끊고 비밀번호를 아는 쪽만 다시 들어오게 하는 것입니다.
한 가지 주의할 점이 있습니다. 브라우저 탭 여러 개가 동시에 Access Token 만료를 감지하고 같은 Refresh Token으로 재발급을 요청하면, 두 번째 요청이 재사용으로 판정되어 정상 사용자가 로그아웃됩니다. 클라이언트에서 재발급 요청을 하나로 묶거나, 서버에서 직전에 폐기된 토큰을 몇 초간 허용하는 식으로 보완합니다.
장면 6: 로그아웃 — POST /auth/logout
@app.post("/auth/logout", status_code=204)
def logout(response: Response, refresh_token: str | None = Cookie(default=None)):
if refresh_token:
try:
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
r.delete(f"refresh:{payload['jti']}")
r.srem(f"user_refresh:{payload['sub']}", payload["jti"])
except jwt.InvalidTokenError:
pass
response.delete_cookie("refresh_token", path="/auth")
서버는 Redis에서 해당 jti를 지우고, 같은 이름과 경로로 Max-Age=0인 쿠키를 내려 브라우저의 쿠키를 지웁니다.
HTTP/1.1 204 No Content
set-cookie: refresh_token=""; Max-Age=0; Path=/auth; ...
이제 Refresh Token은 서버에서도 클라이언트에서도 사라졌습니다. 그런데 로그아웃 전에 받은 Access Token으로 /api/me를 호출해 보면 결과는 이렇습니다.
HTTP/1.1 200 OK
{"user_id": "42"}
로그아웃해도 Access Token은 만료될 때까지 유효합니다. 서버가 Access Token을 저장하지 않으므로 지울 대상 자체가 없기 때문입니다. 클라이언트가 메모리에서 토큰을 버리면 정상 사용 흐름에서는 문제가 없지만, 누군가 토큰을 복사해 두었다면 남은 시간(최대 15분) 동안은 쓸 수 있습니다. Access Token의 수명을 짧게 잡는 이유가 바로 이것입니다.
Refresh Token은 어디에 저장하나
Refresh Token을 다루는 방식은 크게 두 가지입니다.
클라이언트가 직접 저장하는 방식은 응답 본문으로 Refresh Token을 받아 localStorage나 앱 저장소에 두고, 재발급할 때 요청 본문이나 헤더에 직접 넣어 보냅니다. 모바일·네이티브 앱에서는 지금도 이 방식이 표준이며, 저장소로는 iOS Keychain이나 Android Keystore 같은 보안 저장소를 씁니다.
쿠키 방식은 이 글의 예제처럼 서버가 Set-Cookie로 내려주고, 브라우저가 저장했다가 경로가 맞는 요청에 자동으로 붙여 보냅니다. 클라이언트 코드는 토큰 값을 알 필요가 없고, HttpOnly 속성 때문에 알 수도 없습니다.
웹에서 쿠키 방식을 선호하는 이유는 XSS입니다. localStorage에 있는 값은 페이지에 주입된 악성 스크립트가 읽어서 외부로 보낼 수 있지만, HttpOnly 쿠키는 JavaScript로 읽을 수 없습니다.
예제에서 쓴 쿠키 속성을 정리하면 다음과 같습니다.
| 속성 | 의미 |
|---|---|
HttpOnly |
JavaScript(document.cookie)로 읽을 수 없음 |
Secure |
HTTPS 요청에만 전송 |
SameSite=Strict |
다른 사이트에서 시작된 요청에는 전송하지 않음 |
Path=/auth |
/auth로 시작하는 경로에만 전송 |
Max-Age=1209600 |
14일 후 브라우저가 삭제 |
Path를 /auth/refresh가 아니라 /auth로 잡은 것은 로그아웃 엔드포인트에서도 Refresh Token을 받아 Redis에서 지워야 하기 때문입니다. 동시에 /api/... 요청에는 쿠키가 붙지 않습니다. 실제로 확인해 보면 /api/me 요청에는 Cookie 헤더가 아예 없고, /auth/refresh 요청에만 붙습니다. 수명이 긴 토큰이 네트워크에 노출되는 횟수를 최소한으로 줄이는 설정입니다.
Access Token은 응답 본문으로 받아 JavaScript 메모리 변수에 둡니다. 새로고침하면 사라지지만, 그때는 쿠키에 남아 있는 Refresh Token으로 재발급을 받으면 됩니다.
쿠키 방식에서 따로 챙길 것
브라우저가 쿠키를 자동으로 보내기 때문에 생기는 문제가 두 가지 있습니다.
첫째는 CSRF입니다. 다른 사이트가 사용자 브라우저를 통해 우리 서버로 요청을 보내게 만들면 쿠키가 함께 전송될 수 있습니다. SameSite=Strict 또는 Lax로 대부분 막을 수 있고, /auth/refresh가 공격자에게 돌려주는 것은 응답 본문의 Access Token인데 다른 출처에서는 CORS 때문에 그 응답을 읽을 수 없다는 점도 방어에 도움이 됩니다.
둘째는 프론트엔드와 API의 도메인이 다를 때의 설정입니다. 다른 출처로 보내는 fetch는 기본적으로 쿠키를 보내지 않으므로 다음과 같이 지정해야 합니다.
await fetch("https://api.example.com/auth/refresh", {
method: "POST",
credentials: "include",
});
서버는 CORS 응답에 Access-Control-Allow-Credentials: true와 정확한 출처를 지정해야 합니다. 이때 Access-Control-Allow-Origin: *는 쓸 수 없습니다.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
클라이언트는 서명을 검증하나
보통은 검증하지 않습니다. 클라이언트는 토큰을 그대로 보관했다가 서버에 다시 보내기만 하고, 진위 확인은 서버의 일입니다.
HS256처럼 대칭키 방식이라면 검증에 비밀키가 필요하므로 클라이언트는 애초에 검증할 수 없습니다. 비밀키를 클라이언트에 주면 누구나 토큰을 위조할 수 있게 됩니다. 검증하지 않아도 되는 이유는 클라이언트가 토큰을 HTTPS로 서버에게서 직접 받았기 때문입니다. 토큰이 정말 그 서버에서 왔다는 것은 TLS가 이미 보장합니다.
클라이언트가 할 수 있는 일은 Payload를 디코딩해서 exp를 읽고, 만료 직전에 미리 재발급을 요청하는 정도입니다. 이것은 검증이 아니라 단순히 내용을 읽는 것입니다.
const payload = JSON.parse(atob(accessToken.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
const msLeft = payload.exp * 1000 - Date.now(); // 남은 시간
예외는 RS256이나 ES256 같은 공개키 방식입니다. 이 경우 서버는 개인키로 서명하고 공개키를 JWKS로 공개하므로, 공개키를 받은 쪽은 누구든 서명을 검증할 수 있습니다. 대표적인 예가 OAuth 2.0 / OpenID Connect에서 id_token을 받은 애플리케이션이 IdP의 공개키로 검증하는 경우입니다.
Access Token을 즉시 무효화해야 한다면
장면 6에서 본 것처럼 Access Token은 만료 전까지 막을 수 없습니다. 비밀번호 변경이나 계정 정지처럼 즉시 끊어야 하는 상황이 있다면 서버에 상태를 다시 둘 수밖에 없습니다. 흔히 쓰는 방법은 두 가지입니다.
- 블랙리스트: Access Token에도
jti를 넣고, 폐기할 토큰의jti를 Redis에 저장합니다. TTL은 그 토큰의 남은 수명만큼만 주면 되므로 저장소가 무한히 커지지 않습니다. - 토큰 버전: 사용자마다 버전 번호를 저장해 두고 토큰에 발급 당시 버전을 넣습니다. 버전을 올리면 그 이전에 발급된 토큰이 한꺼번에 무효가 됩니다.
어느 쪽이든 매 요청마다 Redis를 조회하게 되므로, 저장소 조회 없이 검증한다는 JWT의 장점은 그만큼 줄어듭니다. 대부분의 서비스는 Access Token 수명을 5~15분으로 짧게 잡는 것으로 충분하다고 보고 이 방법을 쓰지 않습니다.
서명 알고리즘과 algorithms 고정
| 알고리즘 | 서명 | 검증 | 잘 맞는 경우 |
|---|---|---|---|
| HS256 | 비밀키 | 같은 비밀키 | 발급과 검증을 같은 서비스가 함 |
| RS256 / ES256 | 개인키 | 공개키 | 여러 서비스가 각자 검증하거나, 외부에 검증을 맡김 |
어떤 알고리즘을 쓰든 jwt.decode()에 algorithms를 반드시 명시해야 합니다. 토큰의 Header에 적힌 alg를 그대로 믿으면, 공격자가 "alg": "none"으로 서명이 아예 없는 토큰을 만들어 보낼 수 있습니다. 예제처럼 algorithms=["HS256"]으로 고정해 두면 이런 토큰은 401 invalid token으로 거절됩니다. 실제로 서명 없이 만든 다음 토큰을 보내 확인했습니다.
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc5NDI5ODA5MH0.
↑ 서명 부분이 비어 있음
비밀키는 코드에 쓰지 않고 환경 변수 등으로 주입하며, HS256이라면 충분히 긴 랜덤 값(예: openssl rand -hex 32)을 씁니다.
정리
JWT 로그인은 "자주 쓰는 토큰은 저장하지 않고 서명만 검증하고, 드물게 쓰는 토큰은 저장해서 통제한다"는 구조입니다.
- 로그인하면 서버는 수명이 짧은 Access Token과 수명이 긴 Refresh Token을 발급합니다. Refresh Token의
jti만 Redis에 저장합니다. - API 요청은 Access Token의 서명, 만료 시각, 용도(
type)만 확인하고 저장소는 조회하지 않습니다. Payload는 누구나 읽을 수 있지만, 비밀키 없이는 수정할 수 없습니다. - Access Token이 만료되면 Refresh Token으로 두 토큰을 모두 새로 받습니다. 이전 Refresh Token은 폐기되며(Rotation), 폐기된 토큰이 다시 들어오면 탈취로 보고 그 사용자의 토큰을 전부 폐기합니다.
- 로그아웃은 Refresh Token을 지우는 것입니다. Access Token은 만료될 때까지 유효하므로 수명을 짧게 잡습니다.
- 웹에서는 Refresh Token을
HttpOnly,Secure,SameSite, 좁은Path를 지정한 쿠키에, Access Token은 메모리에 둡니다. 모바일 앱은 보안 저장소에 직접 저장합니다. - 클라이언트는 서명을 검증하지 않습니다. 서버는
algorithms를 고정해서 검증합니다.
부록: 예제 전체 코드
import uuid
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
import redis
from fastapi import Cookie, Depends, FastAPI, HTTPException, Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
SECRET_KEY = "change-me-to-a-long-random-string" # 실제로는 환경 변수로 주입
ALGORITHM = "HS256"
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=14)
app = FastAPI()
r = redis.Redis(decode_responses=True)
bearer = HTTPBearer()
# 예제용 사용자 저장소 (실제로는 DB)
USERS = {
"user@example.com": {
"id": "42",
"password_hash": bcrypt.hashpw(b"mypassword123", bcrypt.gensalt()),
}
}
class LoginRequest(BaseModel):
email: str
password: str
def create_token(user_id: str, token_type: str, ttl: timedelta, jti: str | None = None) -> str:
now = datetime.now(timezone.utc)
payload = {"sub": user_id, "type": token_type, "iat": now, "exp": now + ttl}
if jti:
payload["jti"] = jti
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str, expected_type: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError:
raise HTTPException(401, "token expired")
except jwt.InvalidTokenError:
raise HTTPException(401, "invalid token")
if payload.get("type") != expected_type:
raise HTTPException(401, "invalid token type")
return payload
def issue_tokens(response: Response, user_id: str) -> dict:
jti = uuid.uuid4().hex
r.set(f"refresh:{jti}", user_id, ex=REFRESH_TTL)
r.sadd(f"user_refresh:{user_id}", jti)
access = create_token(user_id, "access", ACCESS_TTL)
refresh = create_token(user_id, "refresh", REFRESH_TTL, jti)
response.set_cookie(
"refresh_token", refresh,
httponly=True, secure=True, samesite="strict",
path="/auth", max_age=int(REFRESH_TTL.total_seconds()),
)
return {"access_token": access, "token_type": "bearer"}
def revoke_all(user_id: str) -> None:
key = f"user_refresh:{user_id}"
for jti in r.smembers(key):
r.delete(f"refresh:{jti}")
r.delete(key)
def current_user(cred: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
return decode_token(cred.credentials, "access")["sub"]
@app.post("/auth/login")
def login(body: LoginRequest, response: Response):
user = USERS.get(body.email)
if user is None or not bcrypt.checkpw(body.password.encode(), user["password_hash"]):
raise HTTPException(401, "invalid email or password")
return issue_tokens(response, user["id"])
@app.get("/api/me")
def me(user_id: str = Depends(current_user)):
return {"user_id": user_id}
@app.post("/auth/refresh")
def refresh(response: Response, refresh_token: str | None = Cookie(default=None)):
if refresh_token is None:
raise HTTPException(401, "no refresh token")
payload = decode_token(refresh_token, "refresh")
user_id, jti = payload["sub"], payload["jti"]
if r.delete(f"refresh:{jti}") == 0:
# 서명은 유효한데 저장소에 없다 → 이미 한 번 쓰인 토큰이 다시 들어왔다
revoke_all(user_id)
raise HTTPException(401, "refresh token reused")
r.srem(f"user_refresh:{user_id}", jti)
return issue_tokens(response, user_id)
@app.post("/auth/logout", status_code=204)
def logout(response: Response, refresh_token: str | None = Cookie(default=None)):
if refresh_token:
try:
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
r.delete(f"refresh:{payload['jti']}")
r.srem(f"user_refresh:{payload['sub']}", payload["jti"])
except jwt.InvalidTokenError:
pass
response.delete_cookie("refresh_token", path="/auth")
검증 환경: Python 3.12.3, FastAPI 0.162.2, PyJWT 2.7.0, bcrypt 5.0.0, redis-py 8.1.0. Redis는 fakeredis로 대체하고 FastAPI
TestClient(https://testserver)로 각 장면의 요청을 실행했으며, 글의 응답은 그 결과에서 토큰 문자열만 줄인 것입니다.
'Web 일반' 카테고리의 다른 글
| [Web 일반] IP Binding (0) | 2025.10.09 |
|---|---|
| [Web 일반] HTTP Cache-Control 헤더 (0) | 2025.10.07 |
| [Web 일반] IP 주소 0.0.0.0의 의미와 활용 (0) | 2025.05.31 |
| [Web 일반] HTTPS 동작 원리 (0) | 2025.05.08 |
| [Web 일반] 의존성 관리: Dependency 파일과 Lock 파일 (0) | 2025.04.11 |
