Python FastAPI 입문(7/7)
Python/FastAPI

JWT 인증과 bcrypt 비밀번호 암호화

JWT가 무엇인지 이해하고, bcrypt로 비밀번호를 안전하게 저장한 뒤 로그인 API를 구현한다.

2026-04-10
9 min read
#Python#FastAPI#JWT#bcrypt#인증#보안

왜 인증이 필요한가

6편에서 만든 API는 누구나 호출할 수 있다. /users/1에 GET 요청을 보내면 인증 없이 그냥 응답이 온다.

실제 서비스라면 "이 사람이 로그인한 사람인가?" 를 확인해야 한다. 이를 인증(Authentication) 이라고 한다.

서버가 클라이언트를 인증하는 방법은 여러 가지가 있는데, 요즘 API에서 가장 많이 쓰는 방식이 JWT다.


JWT란?

JWT(JSON Web Token) 는 서버가 클라이언트에게 발급하는 서명된 토큰이다.

로그인 흐름을 살펴보면:

1. 클라이언트가 아이디/비밀번호로 로그인 요청
2. 서버가 검증 후 JWT 토큰을 발급
3. 클라이언트는 이후 요청마다 토큰을 헤더에 담아서 전송
4. 서버는 토큰을 검증해서 누구의 요청인지 확인

세션 방식과 달리 서버가 로그인 상태를 따로 저장하지 않아도 된다. 토큰 자체에 정보가 들어있기 때문이다.

JWT 구조

JWT는 .으로 구분된 3부분으로 이루어진다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsImV4cCI6MTcwMDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
      Header                              Payload                                    Signature
부분역할예시
Header알고리즘 정보{ "alg": "HS256", "typ": "JWT" }
Payload실제 데이터{ "sub": "12345", "exp": 1700000000 }
Signature위변조 방지 서명Header + Payload를 SECRET_KEY로 서명

각 부분은 Base64URL로 인코딩되어 있다. Base64는 암호화가 아니라 인코딩이라서 Payload는 누구나 디코딩해서 볼 수 있다. 민감한 정보(비밀번호 등)는 넣으면 안 된다.

Signature는 서버만 아는 SECRET_KEY로 서명되어 있어서, 클라이언트가 Payload를 임의로 수정하면 서명이 맞지 않아 서버가 감지할 수 있다.


bcrypt란?

비밀번호를 DB에 그냥 평문으로 저장하면 DB가 털렸을 때 모든 사용자 비밀번호가 노출된다.

bcrypt는 비밀번호를 단방향 해시로 변환하는 알고리즘이다.

"password123"  →  "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/lewFBcSPFJRQQ4Ke2"

특징:

  • 단방향 — 해시에서 원본 비밀번호를 복원할 수 없다
  • Salt 자동 포함 — 같은 비밀번호라도 매번 다른 해시가 나온다 (레인보우 테이블 공격 방어)
  • cost factor$2b$12$12가 연산 비용. 높을수록 느려지지만 브루트포스에 강해진다

SHA256 같은 일반 해시와 달리 bcrypt는 느리도록 설계되어 있어서 비밀번호 저장에 적합하다.


설치

pip install python-jose[cryptography] passlib[bcrypt]
  • python-jose — JWT 생성/검증
  • passlib — bcrypt 해싱 라이브러리

프로젝트 구조

app/
├── auth.py          # JWT 생성/검증
├── dependencies.py  # FastAPI 의존성 (토큰 추출)
├── routers/
│   └── auth.py      # 로그인 API
├── services/
│   └── user.py      # 유저 조회/생성
└── models.py        # DB 모델

설정값

# config.py
SECRET_KEY = "your-secret-key-change-this-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

SECRET_KEY는 토큰 서명에 쓰이는 핵심 값이다. 실제 서비스에서는 환경변수로 관리하고, 절대 코드에 하드코딩하면 안 된다.


bcrypt 비밀번호 처리

# auth.py
from passlib.context import CryptContext

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

def hash_password(plain: str) -> str:
    return pwd_context.hash(plain)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)
  • hash_password("1234") → DB에 저장할 해시 문자열 반환
  • verify_password("1234", hashed) → 입력값과 해시가 일치하는지 검증

같은 "1234"를 두 번 해시해도 결과가 다르지만, verify는 내부적으로 Salt를 추출해 비교하므로 정상적으로 동작한다.


JWT 생성/검증

# auth.py
from datetime import datetime, timedelta
from jose import jwt, JWTError
from fastapi import HTTPException
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES

def create_access_token(user_id: int) -> str:
    expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    payload = {
        "sub": str(user_id),  # subject — 보통 유저 ID
        "exp": expire,        # expiration — 만료 시각
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def decode_token(token: str) -> int:
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return int(payload["sub"])  # user_id 반환
    except JWTError:
        raise HTTPException(status_code=401, detail="유효하지 않은 토큰")

decode_token은 토큰이 유효하면 user_id를, 만료되거나 위변조된 토큰이면 401을 반환한다.


의존성 주입으로 토큰 추출

# dependencies.py
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi import Depends
from auth import decode_token

security = HTTPBearer()

def get_current_user_id(
    credentials: HTTPAuthorizationCredentials = Depends(security)
) -> int:
    return decode_token(credentials.credentials)

HTTPBearer는 요청 헤더에서 Authorization: Bearer <token> 형식의 토큰을 자동으로 추출해준다.

이 함수를 라우터에 Depends(get_current_user_id)로 주입하면, 토큰 검증이 자동으로 처리된다.


유저 모델과 서비스

# models.py (SQLAlchemy)
class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    email = Column(String, unique=True, nullable=False)
    hashed_password = Column(String, nullable=False)
# services/user.py
from sqlalchemy.orm import Session
from models import User
from auth import hash_password, verify_password

def create_user(db: Session, email: str, password: str) -> User:
    user = User(email=email, hashed_password=hash_password(password))
    db.add(user)
    db.commit()
    db.refresh(user)
    return user

def authenticate_user(db: Session, email: str, password: str) -> User | None:
    user = db.query(User).filter(User.email == email).first()
    if not user:
        return None
    if not verify_password(password, user.hashed_password):
        return None
    return user

비밀번호를 DB에 저장할 때 hashed_password로 저장하고, 로그인 시 verify_password로 비교한다.


로그인 API

# routers/auth.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from database import get_db
from services.user import authenticate_user
from auth import create_access_token

router = APIRouter(prefix="/auth", tags=["auth"])

class LoginRequest(BaseModel):
    email: str
    password: str

class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)):
    user = authenticate_user(db, body.email, body.password)
    if not user:
        raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다")
    token = create_access_token(user.id)
    return TokenResponse(access_token=token)

로그인 실패 시 "이메일이 없다", "비밀번호가 틀렸다"를 구분해서 알려주면 계정 존재 여부가 노출될 수 있다. 그래서 항상 같은 메시지를 반환하는 게 보안상 올바르다.


인증이 필요한 API 만들기

Depends(get_current_user_id)를 추가하면 해당 라우터는 토큰 없이 접근할 수 없다.

# routers/users.py
from fastapi import APIRouter, Depends
from dependencies import get_current_user_id

router = APIRouter(prefix="/users", tags=["users"])

@router.get("/me")
def get_my_info(user_id: int = Depends(get_current_user_id)):
    return {"user_id": user_id, "message": "내 정보입니다"}

요청 흐름:

클라이언트                   서버
   │                          │
   │  GET /users/me           │
   │  Authorization: Bearer eyJ... ─────────►  HTTPBearer가 토큰 추출
   │                          │                    ↓
   │                          │              decode_token() 검증
   │                          │                    ↓
   │◄─────────────────────────│              user_id를 라우터에 주입
   │  { "user_id": 1 }        │

전체 흐름 정리

회원가입
  └─ POST /auth/register
       └─ hash_password("내비밀번호") → DB 저장

로그인
  └─ POST /auth/login
       └─ verify_password() 검증
       └─ create_access_token(user_id) → 토큰 발급

인증된 API 호출
  └─ GET /users/me
  └─ Authorization: Bearer <토큰>
       └─ HTTPBearer → decode_token() → user_id 추출
       └─ 라우터 실행

JWT와 bcrypt를 함께 쓰면 비밀번호는 해시로 안전하게 저장하고, 로그인 상태는 서버 저장 없이 토큰으로 검증할 수 있다.