개발/FastAPI

FastAPI JWT 로그인 구현 기초: 토큰 인증 흐름 이해하기

notebase 2026. 5. 29. 08:29

FastAPI에서 JWT 로그인 인증이 어떻게 동작하는지 로그인 요청, 토큰 발급, Bearer 헤더 인증, 보호된 API 접근 흐름을 중심으로 정리합니다.

 

FastAPI JWT 로그인은 사용자가 로그인하면 서버가 토큰을 발급하고, 이후 요청에서 그 토큰을 확인해 사용자를 인증하는 방식이다. 핵심은 “로그인 상태를 서버가 계속 들고 있지 않는다”는 점이다.

일반적인 웹 로그인에서는 서버 세션을 떠올리기 쉽다.

사용자가 로그인하면 서버가 세션을 만들고, 브라우저는 쿠키를 들고 다닌다. 요청이 올 때마다 서버는 쿠키에 연결된 세션 정보를 확인한다.

JWT 방식은 조금 다르다.

서버는 로그인 성공 시 서명된 토큰을 만들어 클라이언트에 전달한다. 클라이언트는 이후 API 요청마다 이 토큰을 함께 보낸다. 서버는 토큰의 서명이 유효한지, 만료되지 않았는지 확인한 뒤 요청을 처리한다.

여기서 중요한 점은 JWT가 “암호화된 비밀 저장소”가 아니라는 것이다. JWT 안의 내용은 누구나 디코딩해서 볼 수 있다. 그래서 비밀번호, 주민등록번호, 결제 정보 같은 민감한 값은 넣으면 안 된다.

보통은 사용자를 식별할 수 있는 값 정도만 넣는다.

예를 들면 user_id, username, sub 같은 값이다.


 

FastAPI JWT 로그인 흐름을 먼저 이해하기

코드로 들어가기 전에 흐름부터 잡는 게 좋다.

JWT 로그인은 보통 이렇게 움직인다.

  1. 사용자가 아이디와 비밀번호를 보낸다.
  2. 서버가 사용자 정보를 확인한다.
  3. 비밀번호가 맞으면 Access Token을 만든다.
  4. 클라이언트는 토큰을 저장한다.
  5. 이후 요청마다 Authorization 헤더에 토큰을 담아 보낸다.
  6. 서버는 토큰을 검증하고 사용자를 식별한다.
  7. 토큰이 잘못됐거나 만료되면 401 Unauthorized를 반환한다.

요청 헤더는 보통 이런 형태가 된다.

Authorization: Bearer 발급받은_JWT_토큰

 

Bearer는 “이 토큰을 가진 사용자를 인증된 사용자로 봐 달라”는 의미에 가깝다.

FastAPI에서는 이 흐름을 처리할 때 OAuth2PasswordBearer를 자주 사용한다. 이름은 조금 길지만, 역할은 단순하다.

요청 헤더에서 Bearer 토큰을 꺼내오는 도구라고 보면 된다.


 

프로젝트 준비

먼저 필요한 패키지를 설치한다.

pip install fastapi uvicorn pyjwt passlib[bcrypt] python-multipart

 

각 패키지의 역할은 다음과 같다.

패키지 역할
fastapi API 서버 구현
uvicorn FastAPI 실행 서버
pyjwt JWT 생성 및 검증
passlib[bcrypt] 비밀번호 해싱 및 검증
python-multipart 로그인 폼 데이터 처리

 

FastAPI 공식 문서의 JWT 예제에서는 다른 JWT 라이브러리를 사용하는 경우도 있다. 이 글에서는 pyjwt를 사용한다.

pyjwt는 JWT 생성과 검증에 집중한 라이브러리라 예제 코드 흐름이 단순하다. FastAPI에서 JWT 인증 구조를 익히는 목적이라면 jwt.encode(), jwt.decode()의 역할이 바로 보여서 이해하기 쉽다.

여기서 한 가지 주의할 점이 있다.

패키지는 pyjwt로 설치하지만, 코드에서 불러올 때는 import jwt로 작성한다. import pyjwt가 아니다.

import jwt

 

예외 클래스도 같은 방식으로 가져온다.

from jwt import PyJWTError

 

python-multipartOAuth2PasswordRequestForm을 사용할 때 필요하다. 설치하지 않으면 로그인 요청을 받을 때 오류가 날 수 있다.


 

사용자 데이터와 비밀번호 검증 구조

실제 서비스라면 사용자 정보는 데이터베이스에 저장한다.

여기서는 흐름 이해를 위해 메모리 딕셔너리를 사용한다.

from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

fake_users_db = {
    "kim": {
        "username": "kim",
        "hashed_password": pwd_context.hash("1234"),
        "disabled": False,
    },
    "lee": {
        "username": "lee",
        "hashed_password": pwd_context.hash("1234"),
        "disabled": True,
    },
}

 

비밀번호는 원문 그대로 저장하면 안 된다.

예를 들어 사용자의 비밀번호가 1234라고 해서 DB에 그대로 1234를 넣는 방식은 피해야 한다. DB가 유출되면 사용자의 비밀번호가 바로 노출되기 때문이다.

그래서 보통은 해시된 값을 저장한다.

검증할 때도 원문끼리 비교하지 않는다.

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)

 

사용자 조회 함수도 따로 만든다.

def get_user(username: str):
    user = fake_users_db.get(username)
    if user:
        return user
    return None

 

그리고 로그인 시 사용할 인증 함수를 작성한다.

def authenticate_user(username: str, password: str):
    user = get_user(username)

    if not user:
        return None

    if not verify_password(password, user["hashed_password"]):
        return None

    return user

 

여기서는 사용자가 없거나 비밀번호가 틀리면 None을 반환한다.

실제 서비스에서는 “아이디가 없습니다”, “비밀번호가 틀렸습니다”를 너무 구체적으로 나누지 않는 경우도 있다. 공격자가 존재하는 계정을 추측하는 데 악용할 수 있기 때문이다.


 

JWT Access Token 만들기

JWT를 만들 때는 크게 네 가지가 필요하다.

  • 토큰에 넣을 데이터
  • 비밀 키
  • 서명 알고리즘
  • 만료 시간

예시 코드는 다음과 같다.

from datetime import datetime, timedelta, timezone
import jwt

SECRET_KEY = "change-this-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

def create_access_token(data: dict, expires_delta: timedelta | None = None):
    to_encode = data.copy()

    expire = datetime.now(timezone.utc) + (
        expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    )

    to_encode.update({"exp": expire})

    encoded_jwt = jwt.encode(
        to_encode,
        SECRET_KEY,
        algorithm=ALGORITHM,
    )

    return encoded_jwt

 

여기서 SECRET_KEY는 토큰 서명에 사용된다.

이 값이 노출되면 공격자가 유효한 토큰을 직접 만들 수 있다. 예제에서는 문자열로 적었지만, 실제 서비스에서는 환경 변수나 별도 시크릿 관리 도구로 관리하는 게 좋다.

exp는 토큰 만료 시간이다.

JWT는 한 번 발급되면 서버가 별도 저장소에서 관리하지 않는 경우가 많다. 그래서 만료 시간이 없으면 탈취된 토큰이 계속 사용될 수 있다. Access Token은 보통 짧게 가져간다.

여기서는 30분으로 설정했다.


 

로그인 API 구현하기

이제 로그인 API를 만든다.

FastAPI에서는 OAuth2PasswordRequestForm을 사용하면 username, password 형식의 로그인 폼 데이터를 받을 수 있다.

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from datetime import timedelta

app = FastAPI()

@app.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form_data.username, form_data.password)

    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="아이디 또는 비밀번호가 올바르지 않습니다.",
            headers={"WWW-Authenticate": "Bearer"},
        )

    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)

    access_token = create_access_token(
        data={"sub": user["username"]},
        expires_delta=access_token_expires,
    )

    return {
        "access_token": access_token,
        "token_type": "bearer",
    }

 

응답은 보통 이런 형태다.

{
  "access_token": "JWT_TOKEN_VALUE",
  "token_type": "bearer"
}

 

여기서 sub는 subject의 줄임말이다. 토큰이 누구를 가리키는지 담는 용도로 자주 사용된다.

이 예시에서는 username을 넣었다.


 

보호된 API에서 토큰 검증하기

로그인 API가 토큰을 발급했다면, 이제 토큰이 있어야 접근할 수 있는 API를 만들어야 한다.

먼저 OAuth2PasswordBearer를 설정한다.

from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

 

tokenUrl="token"은 토큰을 발급받는 경로를 의미한다.

앞에서 만든 로그인 API가 /token이므로 이렇게 맞춘다. 이 설정은 Swagger UI의 Authorize 버튼과도 연결된다.

이제 현재 사용자를 가져오는 함수를 만든다.

from jwt import PyJWTError

def get_current_user(token: str = Depends(oauth2_scheme)):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="인증 정보를 확인할 수 없습니다.",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        payload = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=[ALGORITHM],
        )

        username: str | None = payload.get("sub")

        if username is None:
            raise credentials_exception

    except PyJWTError:
        raise credentials_exception

    user = get_user(username)

    if user is None:
        raise credentials_exception

    if user.get("disabled"):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="비활성화된 계정입니다.",
        )

    return user

 

이 함수의 흐름은 단순하다.

  1. 요청 헤더에서 Bearer 토큰을 꺼낸다.
  2. JWT 서명을 검증한다.
  3. 토큰이 만료됐는지 확인한다.
  4. 토큰 안의 sub 값을 꺼낸다.
  5. 해당 사용자가 실제로 존재하는지 확인한다.
  6. 계정이 비활성화된 상태인지 확인한다.
  7. 문제가 없으면 사용자 정보를 반환한다.

여기서 disabled 확인이 빠지면, 정지되었거나 비활성화된 계정도 토큰만 유효하면 API에 접근할 수 있다. 그래서 사용자 조회 후 계정 상태를 한 번 더 확인하는 편이 안전하다.

이제 보호된 API에 적용한다.

@app.get("/users/me")
def read_users_me(current_user: dict = Depends(get_current_user)):
    return {
        "username": current_user["username"],
        "disabled": current_user["disabled"],
    }

 

이 API는 토큰 없이 접근하면 실패한다.

반대로 로그인해서 받은 토큰을 Authorization: Bearer ... 헤더에 넣으면 정상 응답을 받을 수 있다. 다만 disabled 값이 True인 계정은 토큰이 유효해도 접근이 차단된다.


 

전체 코드 예시

아래 코드는 흐름 이해용으로 한 파일에 정리한 예시다.

실제 서비스에서는 사용자 모델, 인증 로직, 설정값, 라우터를 파일별로 나누는 편이 좋다.

# main.py

from datetime import datetime, timedelta, timezone

import jwt
from jwt import PyJWTError
from passlib.context import CryptContext

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


# ==================================================
# 1. 기본 설정
# ==================================================

SECRET_KEY = "change-this-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

app = FastAPI()

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


# ==================================================
# 2. 예시 사용자 데이터
# ==================================================

fake_users_db = {
    "kim": {
        "username": "kim",
        "hashed_password": pwd_context.hash("1234"),
        "disabled": False,
    },
    "lee": {
        "username": "lee",
        "hashed_password": pwd_context.hash("1234"),
        "disabled": True,
    },
}


# ==================================================
# 3. 비밀번호 검증과 사용자 인증
# ==================================================

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)


def get_user(username: str):
    user = fake_users_db.get(username)

    if user:
        return user

    return None


def authenticate_user(username: str, password: str):
    user = get_user(username)

    if not user:
        return None

    if not verify_password(password, user["hashed_password"]):
        return None

    return user


# ==================================================
# 4. JWT Access Token 생성
# ==================================================

def create_access_token(data: dict, expires_delta: timedelta | None = None):
    to_encode = data.copy()

    expire = datetime.now(timezone.utc) + (
        expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    )

    to_encode.update({"exp": expire})

    encoded_jwt = jwt.encode(
        to_encode,
        SECRET_KEY,
        algorithm=ALGORITHM,
    )

    return encoded_jwt


# ==================================================
# 5. Bearer 토큰 검증과 현재 사용자 조회
# ==================================================

def get_current_user(token: str = Depends(oauth2_scheme)):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="인증 정보를 확인할 수 없습니다.",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        payload = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=[ALGORITHM],
        )

        username: str | None = payload.get("sub")

        if username is None:
            raise credentials_exception

    except PyJWTError:
        raise credentials_exception

    user = get_user(username)

    if user is None:
        raise credentials_exception

    if user.get("disabled"):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="비활성화된 계정입니다.",
        )

    return user


# ==================================================
# 6. 로그인 API
# ==================================================

@app.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form_data.username, form_data.password)

    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="아이디 또는 비밀번호가 올바르지 않습니다.",
            headers={"WWW-Authenticate": "Bearer"},
        )

    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)

    access_token = create_access_token(
        data={"sub": user["username"]},
        expires_delta=access_token_expires,
    )

    return {
        "access_token": access_token,
        "token_type": "bearer",
    }


# ==================================================
# 7. 보호된 API
# ==================================================

@app.get("/users/me")
def read_users_me(current_user: dict = Depends(get_current_user)):
    return {
        "username": current_user["username"],
        "disabled": current_user["disabled"],
    }

 

서버 실행은 다음 명령어로 한다.

uvicorn main:app --reload

 

실행 후 아래 주소로 접속한다.

http://127.0.0.1:8000/docs

 


 

Swagger UI에서 테스트하는 방법

FastAPI의 장점 중 하나는 인증 API도 Swagger UI에서 바로 테스트할 수 있다는 점이다.

순서는 다음과 같다.

  1. /docs에 접속한다.
  2. /token API를 연다.
  3. Try it out을 클릭한다.
  4. username에 kim, password에 1234를 입력한다.
  5. 응답으로 access_token을 받는다.
  6. 우측 상단 Authorize 버튼을 누른다.
  7. 토큰 값을 입력한다.
  8. /users/me API를 호출한다.

여기서 헷갈리기 쉬운 부분이 있다.

Swagger UI의 Authorize 창에는 보통 토큰 값만 넣으면 된다. 직접 Bearer까지 붙이지 않아도 되는 경우가 많다.

반면 Postman, curl, 프론트엔드 코드에서 직접 요청을 보낼 때는 아래처럼 헤더를 구성해야 한다.

Authorization: Bearer 발급받은_토큰

 

curl로 요청하면 이런 형태다.

curl -X GET "http://127.0.0.1:8000/users/me" \
  -H "Authorization: Bearer 발급받은_토큰"

 

비활성화 계정 테스트도 해볼 수 있다.

예시 코드에서는 lee 계정의 disabled 값이 True다. 이 계정으로 로그인하면 토큰은 발급되지만, /users/me 요청에서는 403 Forbidden 응답을 받는다.

username: lee
password: 1234

 

이 흐름을 확인하면 “로그인 성공”과 “API 접근 허용”이 항상 같은 의미는 아니라는 점을 이해하기 쉽다.


 

JWT 로그인에서 자주 헷갈리는 부분

JWT는 로그인 상태를 저장하는 방식이 아니다

JWT는 서버 세션과 다르다.

서버가 “이 사용자는 로그인 중이다”라는 상태를 매번 저장해두는 방식이 아니라, 클라이언트가 들고 온 토큰을 서버가 검증하는 방식이다.

그래서 API 서버를 여러 대로 늘릴 때 유리한 점이 있다.

다만 토큰을 즉시 폐기하는 처리는 세션보다 까다로울 수 있다. 예를 들어 사용자가 로그아웃했는데 이미 발급된 Access Token이 아직 만료 전이라면, 서버가 별도 차단 목록을 관리하지 않는 한 토큰은 계속 유효할 수 있다.

이 부분 때문에 실무에서는 Access Token을 짧게 두고 Refresh Token을 함께 설계하는 경우가 많다.


JWT 안에는 민감한 정보를 넣지 않는다

JWT는 서명된 토큰이지, 기본적으로 내용을 숨기는 토큰이 아니다.

즉, 토큰 안의 payload는 디코딩해서 볼 수 있다.

따라서 이런 값은 넣지 않는 게 좋다.

  • 비밀번호
  • 전화번호
  • 주민등록번호
  • 결제 정보
  • 내부 관리자 권한 설명
  • 민감한 개인정보

대신 최소한의 식별자만 넣는 편이 안전하다.

예를 들면 다음 정도다.

{
  "sub": "kim",
  "exp": 1760000000
}

 

권한이 필요한 서비스라면 role이나 scope를 넣기도 하지만, 이 역시 서비스 구조에 따라 신중하게 설계해야 한다.


SECRET_KEY는 코드에 그대로 두지 않는다

예제에서는 설명을 위해 SECRET_KEY를 코드 안에 적었다.

SECRET_KEY = "change-this-secret-key"

 

하지만 실제 서비스에서는 이렇게 관리하면 안 된다.

GitHub에 코드가 올라가거나 서버 접근 권한이 잘못 관리되면 시크릿 키가 노출될 수 있다. 시크릿 키가 노출되면 공격자가 정상 서명을 가진 토큰을 만들 가능성이 생긴다.

실제 프로젝트에서는 환경 변수를 사용하는 방식이 일반적이다.

import os

SECRET_KEY = os.getenv("JWT_SECRET_KEY")

 

.env 파일을 쓴다면 해당 파일은 Git에 올라가지 않도록 .gitignore에 추가해야 한다.


Access Token 만료 시간은 짧게 잡는다

Access Token은 API 접근 권한을 가진 토큰이다.

이 토큰이 길게 살아 있으면 탈취됐을 때 피해 범위가 커진다. 그래서 보통 Access Token은 짧게, Refresh Token은 상대적으로 길게 설정한다.

다만 이 글에서는 흐름을 단순하게 보기 위해 Access Token만 다뤘다.

로그인 유지, 자동 재로그인, 토큰 재발급까지 구현하려면 Refresh Token 구조가 추가로 필요하다.


 

실제 서비스에서는 무엇을 더 추가해야 할까

지금 예시는 JWT 로그인 흐름을 이해하기 위한 최소 구조다.

실제 서비스에 적용하려면 다음 요소를 추가로 고려해야 한다.

항목 이유
데이터베이스 연동 사용자 정보를 메모리가 아니라 DB에서 관리해야 함
비밀번호 해싱 정책 bcrypt, argon2 등 안전한 해싱 방식 사용
환경 변수 관리 SECRET_KEY, DB URL 같은 민감 정보 보호
Refresh Token 로그인 유지와 토큰 재발급 처리
HTTPS 네트워크 구간에서 토큰 탈취 방지
토큰 폐기 전략 로그아웃, 계정 정지, 비밀번호 변경 시 대응
권한 관리 일반 사용자와 관리자 API 분리

 

여기서 중요한 건 처음부터 모든 걸 한 번에 넣지 않는 것이다.

먼저 로그인 요청 → 토큰 발급 → 보호된 API 접근 흐름을 잡고, 그 다음 DB 연동과 Refresh Token으로 확장하는 편이 이해하기 쉽다.


 

마무리 기준

FastAPI JWT 로그인은 코드보다 흐름을 먼저 이해하는 게 중요하다.

핵심은 세 가지다.

사용자가 로그인하면 서버가 JWT를 발급한다. 클라이언트는 이후 요청마다 Authorization: Bearer ... 헤더에 토큰을 담아 보낸다. 서버는 토큰의 서명, 만료 시간, 사용자 식별 정보, 계정 상태를 확인한 뒤 API 접근을 허용한다.

이 구조만 잡히면 OAuth2PasswordBearer, OAuth2PasswordRequestForm, jwt.encode(), jwt.decode()가 각각 어디에 쓰이는지도 자연스럽게 연결된다.

처음 구현할 때는 Access Token 하나로 흐름을 익히고, 실제 프로젝트에서는 DB 연동, 환경 변수, Refresh Token, HTTPS, 토큰 폐기 전략까지 차례로 붙이는 방식이 적절하다.