FastAPI JWT 인증 구현 — 리프레시 토큰과 권한 분리까지 실무에서 겪은 것들

목차

"JWT 인증은 세션보다 간단하다"는 말을 곧이곧대로 믿었다가 화요일 밤을 통째로 날린 적이 있다. FastAPI JWT 인증 구현이 정말 간단했다면, 새벽 1시에 Signature verification failed 에러를 노려보고 있지는 않았을 거다.

솔직히 액세스 토큰 하나만 쓰는 수준이라면 간단한 게 맞다. 문제는 리프레시 토큰이 들어오는 순간부터다. 토큰 두 개를 관리하고, 만료 정책을 나누고, 권한별 접근 제어까지 얹으면 고려할 게 급격히 늘어난다. Flask에서 FastAPI로 어드민 API를 마이그레이션하면서 겪은 시행착오를 정리해본다.

FastAPI JWT 인증이 "간단하다"는 말에 속았다

JWT 인증의 기본 원리 자체는 단순하다. 서버가 비밀 키로 토큰을 서명하고, 클라이언트가 그 토큰을 들고 다니고, 서버는 서명을 검증해서 신원을 확인한다. 세션 저장소가 필요 없으니 stateless하다는 것도 맞다.

근데 "간단하다"고 말하는 대부분의 튜토리얼은 액세스 토큰 하나만 다룬다. 로그인하면 토큰 발급, 요청할 때 Authorization 헤더에 넣어서 보내기. 여기까지는 30분이면 된다.

프로덕션에서 JWT를 쓰려면 최소한 이런 질문에 답해야 한다:

  • 액세스 토큰 만료 시간을 얼마로 잡을 것인가. 15분? 30분? 1시간?
  • 리프레시 토큰은 어디에 저장하는가. 쿠키? 로컬 스토리지? DB?
  • 리프레시 토큰이 탈취되면 어떻게 무효화하는가
  • 관리자와 일반 유저의 API 접근을 어떻게 분리하는가
  • 토큰 payload에 어떤 정보를 넣고, 어떤 정보는 넣지 않을 것인가

화요일 저녁, M1 맥북에서 PyCharm 띄워놓고 Flask 코드를 FastAPI로 옮기던 중이었다. 기존 Flask 앱에서는 flask-jwt-extended가 리프레시 토큰 관리를 알아서 해줬기 때문에 깊이 생각해본 적이 없었다. FastAPI에는 그런 올인원 패키지가 없다. 토큰 생성, 검증, 갱신 로직을 직접 짜야 한다. 그 "직접"에 함정이 많았다.

python-jose 대신 PyJWT를 쓰는 이유

FastAPI 공식 문서의 보안 튜토리얼은 python-jose[cryptography]를 사용한다. 2024년까지는 이게 사실상 표준이었다.

문제는 python-jose의 관리 상태다. GitHub 리포지터리(mpdavis/python-jose)의 마지막 릴리즈가 3.3.0이고, 2021년 이후 의미 있는 업데이트가 없다. 이슈와 PR이 쌓여 있는데 머지가 안 되고 있다. 2025년 중반부터 커뮤니티에서 PyJWT나 joserfc로 이동하는 흐름이 뚜렷해졌다.

PyJWT(2026년 4월 기준 v2.9 계열)는 활발하게 관리되고 있고, 서명·검증 중심의 일반적인 JWT 사용 사례를 충분히 커버한다. python-jose가 제공하는 JWE(암호화)나 JWK 관리까지 필요하면 authlib 쪽의 joserfc가 대안이 된다.

# python-jose 대신 PyJWT + passlib 설치
pip install PyJWT[crypto] passlib[bcrypt] python-multipart

이 글의 코드는 전부 PyJWT 기준이다. python-jose에서 넘어오는 경우, from jose import jwtimport jwt로 바꾸고 jwt.decode()algorithms 파라미터를 리스트로 넘기는 부분만 주의하면 마이그레이션은 크게 어렵지 않다.

토큰 설계에서 먼저 정해야 할 것들

코드를 치기 전에 결정해야 할 게 있다. 토큰 구조를 나중에 바꾸면 기존 사용자의 토큰이 전부 무효화되기 때문에, 초기 설계가 중요하다.

SECRET_KEY 분리

이건 내가 3시간을 날린 부분이다. 액세스 토큰과 리프레시 토큰의 서명 키는 반드시 분리해야 한다.

같은 키를 쓰면 기능적으로는 동작한다. 보안상 문제가 생긴다. 액세스 토큰은 클라이언트에서 자주 노출된다 — 로컬 스토리지, 네트워크 요청 헤더 등. 만약 액세스 토큰의 서명 키가 유출되면, 같은 키를 쓰는 리프레시 토큰까지 위조가 가능해진다. 키를 분리하면 한쪽이 뚫려도 다른 쪽은 독립적으로 안전하다.

내가 겪은 에러는 이것보다 더 단순한 실수였다. 생성할 때는 두 토큰 모두 ACCESS_SECRET_KEY로 서명하고, 검증 시에는 리프레시 토큰에 REFRESH_SECRET_KEY를 쓴 거다. 변수명을 복사-붙여넣기 하다가 벌어진 일이다. jwt.decode() 시점에 Signature verification failed가 뜨는데, jwt.io에서 payload를 디코딩하면 내용이 정상으로 보이니까 원인 좁히기가 어려웠다. 새벽 1시에 겨우 발견했을 때의 허탈함은 아직도 생생하다.

교훈은 명확했다. 토큰 관련 설정은 하나의 설정 객체로 모아서 관리하는 게 낫다.

from pydantic_settings import BaseSettings

class AuthSettings(BaseSettings):
    ACCESS_SECRET_KEY: str
    REFRESH_SECRET_KEY: str
    ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
    REFRESH_TOKEN_EXPIRE_DAYS: int = 7
    ALGORITHM: str = "HS256"

    # .env에서 자동 로드
    class Config:
        env_file = ".env"

auth_settings = AuthSettings()

이렇게 해두면 auth_settings.ACCESS_SECRET_KEYauth_settings.REFRESH_SECRET_KEY를 헷갈릴 일이 줄어든다. 적어도 에디터 자동완성이 실수를 잡아준다.

만료 시간 전략

액세스 토큰은 짧게, 리프레시 토큰은 길게. 이건 이미 알고 있을 것이다. 구체적인 숫자는 서비스 성격에 따라 달라진다.

내부 어드민 API라면 액세스 토큰 30분, 리프레시 토큰 7일 정도가 적당했다. 사용 빈도가 높으니 30분마다 갱신되어도 UX에 문제가 없고, 7일이면 주말 지나고 월요일에 재로그인하는 자연스러운 주기다. 외부 사용자 대상 서비스에서는 액세스 토큰 15분, 리프레시 토큰 30일이 일반적이다. 금융이나 의료 쪽은 액세스 토큰을 5분까지 줄이기도 한다.

payload에 넣을 것과 넣지 말 것

JWT payload에는 sub(사용자 식별자), exp(만료), type(access/refresh 구분) 정도면 충분하다. 사용자 이름이나 이메일은 넣어도 무방하지만, 비밀번호 해시나 전화번호 같은 민감 정보는 넣으면 안 된다. JWT는 서명만 할 뿐, 암호화는 하지 않는다. Base64 디코딩만 하면 payload를 누구나 읽을 수 있다.

권한(role) 정보를 payload에 넣을지 말지는 트레이드오프다. 넣으면 매 요청마다 DB 조회 없이 권한 체크가 가능하다. 안 넣으면 권한이 변경됐을 때 토큰 재발급 없이 즉시 반영된다. 나는 role을 payload에 포함시키고, 권한 변경 시 해당 유저의 리프레시 토큰을 무효화하는 방식을 택했다.

생성과 검증 코드 — 핵심만

토큰 생성 함수부터 보자.

import jwt
from datetime import datetime, timedelta, timezone

def create_token(data: dict, token_type: str = "access") -> str:
    to_encode = data.copy()
    now = datetime.now(timezone.utc)

    if token_type == "access":
        expire = now + timedelta(minutes=auth_settings.ACCESS_TOKEN_EXPIRE_MINUTES)
        secret = auth_settings.ACCESS_SECRET_KEY
    else:
        expire = now + timedelta(days=auth_settings.REFRESH_TOKEN_EXPIRE_DAYS)
        secret = auth_settings.REFRESH_SECRET_KEY

    to_encode.update({
        "exp": expire,
        "iat": now,
        "type": token_type  # 토큰 종류를 payload에 명시
    })

    return jwt.encode(to_encode, secret, algorithm=auth_settings.ALGORITHM)

type 필드를 굳이 넣느냐. 액세스 토큰을 리프레시 엔드포인트에 보내거나, 리프레시 토큰을 인증 헤더에 넣는 실수를 서버 단에서 잡을 수 있어서다. SECRET_KEY가 다르니 서명 검증에서 걸리긴 하지만, type 필드로 한 번 더 확인하면 디버깅할 때 에러 원인을 바로 특정할 수 있다.

검증 쪽은 FastAPI의 의존성 주입과 엮인다. OAuth2PasswordBearer를 쓰면 Authorization 헤더에서 토큰을 자동으로 추출해준다.

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")

async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="토큰 검증 실패",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(
            token,
            auth_settings.ACCESS_SECRET_KEY,
            algorithms=[auth_settings.ALGORITHM]
        )
        # 리프레시 토큰이 인증 헤더로 들어온 경우 차단
        if payload.get("type") != "access":
            raise credentials_exception
        user_id: str = payload.get("sub")
        if user_id is None:
            raise credentials_exception
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="토큰 만료",
        )
    except jwt.InvalidTokenError:
        raise credentials_exception

    # DB에서 유저 조회
    user = await get_user_by_id(user_id)
    if user is None:
        raise credentials_exception
    return user

jwt.ExpiredSignatureError를 별도로 잡은 건 의도적이다. 클라이언트 입장에서 "토큰이 잘못됐다"와 "토큰이 만료됐다"는 대응이 다르다. 만료면 리프레시 토큰으로 갱신을 시도하면 되고, 그 외 오류면 재로그인이 필요하다.

리프레시 토큰 로테이션에서 터진 것들

리프레시 토큰 로테이션(rotation)은 보안상 권장되는 패턴이다. 리프레시 토큰을 사용할 때마다 새 리프레시 토큰을 발급하고, 이전 것은 폐기한다. 이렇게 하면 리프레시 토큰이 탈취됐을 때, 정상 사용자와 공격자 중 하나가 먼저 토큰을 쓰면 다른 쪽의 토큰이 무효화된다.

개념은 깔끔한데, 구현하면서 예상 못 한 구간이 있었다.

첫 번째: 네트워크 재시도 문제. 클라이언트가 /auth/refresh를 호출했는데 응답이 느려서 같은 리프레시 토큰으로 또 요청을 보내는 경우다. 서버는 첫 번째 요청에서 이미 토큰을 교체했으니 두 번째 요청은 "폐기된 토큰"으로 판정한다. 정상 사용자인데 강제 로그아웃시키는 셈이다. 이걸 해결하려면 리프레시 토큰에 짧은 유예 시간(grace period)을 두거나, 토큰 패밀리(family) 개념을 도입해야 한다. 나는 유예 시간 방식을 택했다. 리프레시 토큰 교체 후 10초간은 이전 토큰도 유효하게 처리한다.

두 번째: DB 정합성. 리프레시 토큰을 DB에 저장하고 교체할 때 UPDATE를 치는데, 동시 요청이 오면 race condition이 생긴다. DB 레벨에서 비관적 잠금(SELECT ... FOR UPDATE)을 걸거나, 토큰 교체를 원자적으로 처리해야 한다. 나는 PostgreSQL의 FOR UPDATE SKIP LOCKED를 써서, 이미 잠긴 레코드에 대한 요청은 바로 실패시키는 방식으로 풀었다. 완벽하진 않지만 어드민 API 수준에서는 충분했다.

세 번째는 리프레시 토큰 저장 위치다. 이건 프론트엔드와 협의가 필요한 부분이라 백엔드 혼자 정할 수 없는데, httpOnly 쿠키가 가장 안전하다는 게 일반적 합의다. 로컬 스토리지는 XSS에 취약하고, 일반 쿠키는 JavaScript로 접근이 가능하다. httpOnly + Secure + SameSite=Strict 조합이면 대부분의 공격 벡터를 차단할 수 있다.

FastAPI Depends로 역할 기반 접근 제어 구현하기

FastAPI에서 role-based access control을 구현하는 가장 깔끔한 방법은 Depends를 체이닝하는 거다. 결론부터 코드로 보자.

from fastapi import Depends

def require_role(*allowed_roles: str):
    """특정 역할만 접근 가능한 의존성"""
    async def role_checker(current_user: dict = Depends(get_current_user)):
        if current_user.get("role") not in allowed_roles:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"권한 부족. 필요: {allowed_roles}, 현재: {current_user.get('role')}"
            )
        return current_user
    return role_checker

# 관리자 전용 엔드포인트
@app.get("/admin/users")
async def list_users(user: dict = Depends(require_role("admin", "superadmin"))):
    return await fetch_all_users()

# 분석가 + 관리자 접근 가능
@app.get("/reports")
async def get_reports(user: dict = Depends(require_role("admin", "analyst"))):
    return await fetch_reports(user["id"])

get_current_user가 반환하는 객체에 role 정보가 포함돼 있어야 동작한다. payload에서 꺼내든 DB에서 조회하든 방법은 자유다.

여기서 내가 겪은 두 번째 사고가 있다. 마이그레이션 2주 후에 QA팀에서 "일반 유저 토큰으로 관리자 API를 호출해도 200이 온다"고 보고했다. 확인해보니 role_checker 함수 안에서 current_user.get("role")이 아니라 current_user.get("roles")로 써놓은 거다. 복수형 오타. None not in allowed_roles는 항상 True여서 모든 요청이 조용히 통과하고 있었다. 타입 체크가 없으니 런타임에서 에러도 안 났다.

이런 실수를 방지하려면 User를 Pydantic BaseModel로 정의하고, role 필드 타입을 Enum으로 제한하는 게 좋다. dict 대신 타입이 있는 객체를 쓰면 오타가 IDE 단계에서 잡힌다. JWT 인증 자체의 문제는 아니지만, 인증/인가 코드에서 타입 안전성이 생각보다 많은 걸 잡아준다는 걸 그때 느꼈다.

토큰 무효화, Redis 없이 어디까지 가능한가

JWT의 구조적 한계는 서버 측에서 토큰을 "취소"할 방법이 없다는 거다. 발급된 토큰은 만료될 때까지 유효하다. 사용자가 비밀번호를 바꾸거나, 관리자가 특정 계정을 차단해야 할 때 문제가 된다.

일반적인 해법은 Redis에 블랙리스트를 두는 건데, 모든 프로젝트에 Redis가 있는 건 아니다. 소규모 서비스에서 JWT 블랙리스트 하나 때문에 Redis를 도입하는 건 과할 수 있다.

Redis 없이 쓸 수 있는 방식은 세 가지 정도 있다.

1) 액세스 토큰 만료를 극단적으로 짧게 잡기. 5분이면, 토큰 무효화가 필요한 상황에서 최대 5분만 기다리면 된다. 리프레시 토큰은 DB에서 삭제하면 갱신이 차단된다. 완벽한 "즉시 차단"은 안 되지만, 대부분의 비즈니스 요구사항에서 5분은 수용 가능한 범위로 보인다.

2) DB 기반 토큰 버전. 유저 테이블에 token_version 컬럼을 추가하고, 토큰 payload에 이 버전을 포함시킨다. 비밀번호 변경이나 강제 로그아웃 시 버전을 올리면, 이전 버전 토큰은 검증 시 거부된다. 매 요청마다 DB 조회가 들어가서 stateless의 장점을 잃긴 하지만, 블랙리스트보다 구현이 단순하다.

3) 인메모리 Set. 서버가 단일 인스턴스라면 Python의 set에 무효화된 토큰 ID(jti)를 넣어두는 것만으로도 된다. 서버 재시작하면 날아가지만, 만료 시간이 짧으면 실질적 문제는 크지 않다. 스케일 아웃하면 못 쓰니까, 정말 작은 서비스 한정이다.

나는 어드민 API에서 2번 방식을 운영 중이다. 관리자 계정이 20개 남짓이라 매 요청마다 DB를 한 번 더 찌르는 게 성능에 영향을 줄 수준이 아니었다.

FastAPI JWT 인증 구현에서 놓치기 쉬운 것들

CORS 설정에서 allow_credentials=True를 안 켜놓고 "쿠키가 안 보내진다"며 헤맨 적이 있다. httpOnly 쿠키로 리프레시 토큰을 보내려면 CORS에서 credentials를 허용해야 하고, 이때 allow_origins에 와일드카드(*)를 쓸 수 없다. 구체적인 오리진을 명시해야 한다. FastAPI 공식 CORS 문서(https://fastapi.tiangolo.com/tutorial/cors/)에 나와 있는 내용인데 인증 파트에서는 언급이 안 돼서 놓치기 쉽다.

HTTPS가 아닌 환경에서 Secure 쿠키가 전송되지 않는 것도 흔한 실수다. 로컬 개발에서는 Secure=False로 두고, 프로덕션 배포 시 환경변수로 전환하면 된다.

PyJWT v2.9 기준으로 jwt.decode()에서 algorithms 파라미터를 누락하면 키 관련 에러처럼 보이는 메시지가 뜨는데, 실제로는 algorithms 미지정이 원인이다. algorithms는 반드시 리스트로 넘기자. algorithms=["HS256"]처럼. (출처: PyJWT 공식 문서 https://pyjwt.readthedocs.io/en/stable/usage.html)

다음에는 OAuth2 소셜 로그인(Google, GitHub)을 FastAPI JWT 인증 구현 위에 얹는 걸 실험해볼 생각이다.

관련 글

Chiko IT
Chiko IT

Platform Engineer. Python, AI, Infra에 관심이 많습니다.