패스워드(해싱)와 Bearer, JWT 토큰을 쓰는 OAuth2
패스워드(해싱)와 Bearer, JWT 토큰을 쓰는 OAuth2 (OAuth2 with Password (and hashing), Bearer with JWT tokens)
이제 보안 흐름의 모든 조각이 모였어요. 마지막으로 실제로 안전한 앱을 만드는 단계예요. 이번 장에서는 JWT 토큰(서명된 토큰)과 안전한 패스워드 해싱을 써서, 실제 프로덕션에서 그대로 써도 될 만한 보안을 구성해요. 패스워드 해시를 데이터베이스에 저장하는 것까지요.
출처: 공식문서
직전 장에서 만든 구조를 이어받아서 한 단계씩 업그레이드할 거예요.
JWT에 대해
JWT는 "JSON Web Tokens"의 약자예요. JSON 객체를 공백 없이 길고 빽빽한 문자열 하나로 담아내는 표준이에요. 생긴 건 이렇게 생겼어요.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
중요한 특징 두 가지를 알아 두세요.
- 암호화되지 않아요. 누구나 내용물을 다시 꺼내 볼 수 있어요.
- 그런데 서명(sign)돼 있어요. 그래서 여러분이 발급한 토큰을 다시 받았을 때, "이걸 내가 발급했다"는 사실을 검증할 수 있어요.
이 조합 덕분에 이런 흐름이 가능해져요. 토큰에 만료 시간을 넣어 발급해서, 다음 날 사용자가 그 토큰을 들고 오면 "아직 로그인 상태다"라고 알 수 있어요. 일주일이 지나 토큰이 만료되면 권한이 없어져서 다시 로그인해야 하고요. 그리고 만약 누군가 토큰을 조작해서 만료 시간을 바꾸려고 하면, 서명이 맞지 않아서 바로 들통나요.
JWT를 직접 가지고 놀며 확인해 보고 싶다면 https://jwt.io를 추천해요.
PyJWT 설치하기
Python에서 JWT 토큰을 생성하고 검증하려면 PyJWT가 필요해요.
$ uv add pyjwt
참고
RSA나 ECDSA 같은 디지털 서명 알고리즘을 쓸 계획이라면,
pyjwt[crypto]처럼 cryptography 의존성까지 같이 설치해야 해요. 자세한 내용은 PyJWT 설치 문서에서 확인할 수 있어요.
패스워드 해싱
해싱은 어떤 내용(여기선 패스워드)을 알아볼 수 없는 바이트 시퀀스(그냥 문자열처럼 보이는 것)로 바꾸는 작업이에요. 똑같은 내용(똑같은 패스워드)을 넣으면 항상 똑같은 결과가 나오지만, 그 결과에서 원래 패스워드로는 되돌릴 수 없어요.
왜 해싱을 쓸까
만약 데이터베이스가 유출된다면, 도둑은 사용자들의 평문 패스워드가 아니라 해시만 갖게 돼요. 그래서 도둑이 그 패스워드를 다른 시스템에서 시도해 보는 걸 막을 수 있어요. 여러 곳에 같은 패스워드를 쓰는 사용자가 많다 보니, 평문이 그대로 새는 건 정말 위험하거든요.
pwdlib 설치하기
pwdlib은 패스워드 해시를 다루기 좋은 훌륭한 Python 패키지예요. 안전한 해싱 알고리즘 여러 개와 그걸 다루는 유틸리티를 지원해요. 권장 알고리즘은 **"Argon2"**예요.
$ uv add "pwdlib[argon2]"
팁
pwdlib를 설정하면 Django나 Flask 보안 플러그인 등에서 만든 해시도 읽을 수 있게 만들 수 있어요. 그러면 예를 들어 Django 앱과 FastAPI 앱이 같은 데이터베이스를 공유하거나, 같은 DB를 쓰며 점진적으로 마이그레이션하는 것도 가능해요. 사용자 입장에서는 Django 앱이든 FastAPI 앱이든 동시에 같은 계정으로 로그인할 수 있게 되는 거죠.
패스워드 해싱과 검증
pwdlib에서 필요한 도구를 import 해요. 그리고 권장 설정으로 PasswordHash 인스턴스를 만들어요. 이게 패스워드 해싱과 검증에 사용돼요.
팁
pwdlib는 bcrypt 해싱 알고리즘도 지원하지만, 레거시 알고리즘은 포함하지 않아요. 오래된 해시를 다뤄야 한다면passlib라이브러리를 권장해요. 예를 들어 다른 시스템(예: Django)에서 만든 해시는 읽어서 검증하고, 새 패스워드는 Argon2나 Bcrypt처럼 다른 알고리즘으로 해싱하면서 동시에 여러 방식과 호환되게 만들 수 있어요.
이제 패스워드를 해싱하는 유틸리티 함수, 받은 패스워드가 저장된 해시와 맞는지 검증하는 함수, 그리고 인증하고 사용자를 돌려주는 함수를 만들어요.
전체 코드를 먼저 보여줄게요.
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel
# to get a string like this run:
# openssl rand -hex 32
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
fake_users_db = {
"johndoe": {
"username": "johndoe",
"full_name": "John Doe",
"email": "[email protected]",
"hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$wagCPXjifgvUFBzq4hqe3w$CYaIb8sB+wtD+Vu/P4uod1+Qof8h+1g7bbDlBID48Rc",
"disabled": False,
}
}
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
class User(BaseModel):
username: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
class UserInDB(User):
hashed_password: str
password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("dummypassword")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
def verify_password(plain_password, hashed_password):
return password_hash.verify(plain_password, hashed_password)
def get_password_hash(password):
return password_hash.hash(password)
def get_user(db, username: str):
if username in db:
user_dict = db[username]
return UserInDB(**user_dict)
def authenticate_user(fake_db, username: str, password: str):
user = get_user(fake_db, username)
if not user:
verify_password(password, DUMMY_HASH)
return False
if not verify_password(password, user.hashed_password):
return False
return user
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except InvalidTokenError:
raise credentials_exception
user = get_user(fake_users_db, username=token_data.username)
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
@app.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
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 Token(access_token=access_token, token_type="bearer")
@app.get("/users/me/")
async def read_users_me(
current_user: Annotated[User, Depends(get_current_active_user)],
) -> User:
return current_user
@app.get("/users/me/items/")
async def read_own_items(
current_user: Annotated[User, Depends(get_current_active_user)],
):
return [{"item_id": "Foo", "owner": current_user.username}]
JWT 토큰 처리하기
SECRET_KEY는 토큰 서명에 쓰는 비밀값이에요. 예제에 있는 값을 그대로 쓰지 말고, 직접 만들어야 해요. 좋은 방법은 터미널에서 이 명령으로 랜덤 키를 만드는 거예요.
$ openssl rand -hex 32
09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7
출력된 값을 SECRET_KEY 변수에 넣으면 돼요. 그리고:
ALGORITHM = "HS256"— 토큰 서명에 쓸 알고리즘을 정해요.ACCESS_TOKEN_EXPIRE_MINUTES = 30— 토큰 만료 시간을 정해요.TokenPydantic 모델 — 토큰 엔드포인트의 응답 형태를 정의해요.
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
create_access_token은 데이터에 exp(만료 시각)를 더해서 SECRET_KEY와 ALGORITHM으로 서명한 JWT를 만들어 돌려줘요. 만료 시간을 따로 안 넘기면 기본 15분으로 만들죠.
의존성 업데이트하기
get_current_user도 이제 진짜 토큰을 검증하도록 바꿔요. 토큰을 SECRET_KEY와 ALGORITHM으로 jwt.decode해서 안의 sub(subject, 주제)를 꺼내고, 그 값으로 사용자를 찾아요.
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except InvalidTokenError:
raise credentials_exception
user = get_user(fake_users_db, username=token_data.username)
if user is None:
raise credentials_exception
return user
검증에 실패하거나(토큰이 서명과 안 맞거나) 사용자를 못 찾으면 credentials_exception(401)을 던져요. 토큰이 조작됐는지, 만료됐는지 같은 건 InvalidTokenError로 잡혀서 이 예외로 바뀌죠.
/token 경로 동작 업데이트하기
로그인 동작도 실제로 토큰을 발급하도록 바꿔요. authenticate_user로 사용자를 검증하고, 성공하면 create_access_token으로 JWT를 만들어 Token 응답으로 돌려줘요.
@app.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
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 Token(access_token=access_token, token_type="bearer")
{"sub": user.username}의 sub는 JWT 표준 필드인 "subject"를 뜻해요. 여기엔 사용자를 식별하는 값(여기선 username)을 넣는 게 일반적이에요.
JWT "subject" sub에 대한 기술적 세부 사항
JWT 스펙에서 sub는 subject(주제), 즉 이 토큰이 가리키는 대상을 뜻해요. 보통 사용자 ID 같은 식별자가 들어가요. FastAPI는 sub를 특별히 강제하지는 않지만, 로그인 동작에서 사용자 식별자를 sub에 담아 발급하고, 인증 의존성에서는 그 sub를 꺼내 다시 사용자를 찾는 패턴이 자연스럽게 맞물려요.
참고
authenticate_user에서 사용자가 없을 때도verify_password(password, DUMMY_HASH)를 호출하는 걸 볼 수 있어요. 이건 타이밍 공격을 막으려는 의도예요. 사용자가 없으면False를 돌려주긴 하는데, 그 과정에서 가짜 해시와의 비교를 한 번 돌려서 "사용자 없음"과 "패스워드 틀림"의 응답 시간 차이를 없애는 거죠. 꼭 이렇게 해야 하는 건 아니지만, 실제 서비스에서 신경 쓸 만한 디테일이에요.
직접 확인해 보기
앱을 실행하고 문서 UI(Swagger UI)로 가서 Authorize를 눌러 보세요. 사용자 johndoe(패스워드 secret)로 로그인하면 그때부터 보호된 엔드포인트를 호출할 수 있어요.
GET /users/me/— 현재 사용자 정보를 돌려줘요.GET /users/me/items/— 현재 사용자의 아이템 목록을 돌려줘요.
scopes를 활용한 고급 사용
JWT와 OAuth2를 이렇게 구성하면, 권한을 더 세밀하게 나누는 **OAuth2 scopes**도 쓸 수 있어요. 많은 대형 인증 제공자(Facebook, Google, GitHub, Microsoft, X(Twitter) 등)가 사용자를 대신해 써드파티 앱이 API와 상호작용하도록 허가할 때 이 메커니즘을 써요. 자세한 내용은 Advanced User Guide의 OAuth2 scopes 장에서 다룰게요.
요약
이번 장을 거쳐 우리는 실제로 쓸 수 있는 보안 흐름을 완성했어요. 몇 가지 핵심만 정리하면 이래요.
- FastAPI는 어떤 데이터베이스나 데이터 모델, 도구와도 타협하지 않아요. 자유롭게 프로젝트에 맞는 걸 고르면 돼요.
pwdlib,PyJWT처럼 널리 쓰이고 잘 관리되는 패키지를 직접 그대로 쓸 수 있어요. FastAPI는 외부 패키지 통합을 위해 복잡한 메커니즘을 요구하지 않거든요.- 그러면서도 OAuth2 같은 안전한 표준 프로토콜을 상대적으로 간단하게 구현할 도구를 제공해요.
한 번에 이해하려 하지 말고, 흐름(로그인 → 토큰 발급 → 토큰 검증 → 현재 사용자)을 따라 직접 코드를 만져 보는 걸 권해요.
더 알아보기 (Learn more)
- 공식문서: OAuth2 with Password (and hashing), Bearer with JWT tokens
- PyJWT 설치 문서: PyJWT Installation
- JWT 알아보기: https://jwt.io
- 더 세밀한 권한: OAuth2 Scopes (Advanced)