FastAPI 교재 · 9편 · 인증

FastAPI 인증 — OAuth2와 JWT 토큰

"너 누구냐"를 묻는 로그인부터 "이거 해도 되냐"를 따지는 보호된 라우트까지. 비밀번호 해싱과 JWT 토큰으로 인증을 제대로 세운다.

JWT 토큰 열쇠가 보호된 API 엔드포인트를 여는 인증 흐름과 방패·자물쇠가 서버를 지키는 아이소메트릭 일러스트

지금까지 우리가 만든 API 는 누구나 마음대로 들어올 수 있는 문 이었다. /items 를 부르면 누구든 목록을 보고, POST 를 날리면 누구든 데이터를 쓴다. 작은 토이 프로젝트라면 괜찮지만, 진짜 서비스라면 곧장 질문이 생긴다 — "이 요청을 보낸 게 누구지? 이 사람이 이걸 봐도 되나?"

그 질문에 답하는 게 인증(authentication)인가(authorization) 다. 9편은 FastAPI 의 표준 인증 방식인 OAuth2 + JWT 를 처음부터 끝까지 짠다. 회원의 비밀번호를 안전하게 저장하고, 로그인하면 토큰을 발급하고, 그 토큰이 있어야만 들어올 수 있는 라우트를 만든다. 보안은 한 번 헷갈리면 통째로 무너지는 영역이라, 흔한 실수들도 같이 짚는다.

1. 인증과 인가 — 헷갈리면 안 되는 두 단어

두 단어가 비슷해 보여서 자주 섞이는데, 한 문장으로 가르면 평생 안 헷갈린다.

인증(Authentication) = "너 누구냐" — 신원을 확인하는 일. 로그인해서 "나는 김철수입니다"를 증명하는 단계.
인가(Authorization) = "너 이거 해도 되냐" — 권한을 확인하는 일. 김철수가 관리자 페이지에 들어갈 자격이 있는지 따지는 단계.

공항으로 비유하면 — 인증은 여권을 보여 주고 "본인이 맞다"를 확인받는 일이고, 인가는 그 여권에 찍힌 비자로 "이 나라에 들어갈 수 있다"를 확인받는 일이다. 순서도 항상 인증이 먼저다. 누구인지부터 알아야 그 사람이 뭘 할 수 있는지 따질 수 있으니까.

이번 편은 대부분 인증에 집중한다. "로그인한 사용자만 들어올 수 있다"까지가 9편의 목표고, 등급별 권한(관리자 vs 일반)을 나누는 인가는 그 위에 얹는 응용이다. 인증이 단단하면 인가는 조건문 한 줄로 끝난다.

2. 비밀번호 해싱 — 평문 저장은 사고다

인증의 출발점은 비밀번호다. 그런데 여기서 초보가 가장 크게 사고를 친다. 비밀번호를 DB 에 그대로(평문으로) 저장하는 것이다. 절대 안 된다. DB 가 한 번 털리면 모든 회원의 비밀번호가 그대로 노출되고, 사람들은 같은 비밀번호를 여러 사이트에 돌려쓰기 때문에 피해가 우리 서비스에서 끝나지 않는다.

해법은 해싱(hashing) 이다. 비밀번호를 되돌릴 수 없는 형태로 바꿔서 저장한다. mypassword123$2b$12$Kx... 같은 긴 문자열이 되는데, 이건 원래 값으로 되돌릴 수 없다. 로그인할 때는 입력받은 비밀번호를 같은 방식으로 해싱해서, 저장된 해시와 일치하는지만 비교한다.

FastAPI 진영에서는 passlib 라이브러리에 bcrypt 알고리즘을 얹어 쓴다. 먼저 설치부터.

$ pip install "python-jose[cryptography]" "passlib[bcrypt]"

두 패키지를 한 번에 깔았다. passlib[bcrypt] 는 해싱용, python-jose[cryptography] 는 잠시 뒤 JWT 발급에 쓴다. 이제 해시를 만들고 검증하는 헬퍼를 짠다.

# security.py from passlib.context import CryptContext pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def hash_password(plain: str) -> str: # 회원가입 시 평문을 해시로 바꿔 DB 에 저장 return pwd_context.hash(plain) def verify_password(plain: str, hashed: str) -> bool: # 로그인 시 입력값과 저장된 해시를 비교 (True / False) return pwd_context.verify(plain, hashed)

CryptContext 가 핵심이다. schemes=["bcrypt"] 는 "bcrypt 로 해싱하라"는 뜻이고, deprecated="auto" 는 나중에 더 강한 알고리즘으로 바꿀 때 옛 해시도 자동으로 인식하게 해 주는 옵션이다. hash_password 는 회원가입 때 호출하고, verify_password 는 로그인 때 입력값을 검증한다. bcrypt 는 같은 비밀번호라도 매번 다른 해시를 내놓지만(내부에 무작위 salt 가 섞인다), verify 가 그걸 알아서 처리하니 우리는 신경 쓸 게 없다.

보안 주의 — 절대 하지 말 것 — ① 비밀번호를 평문 그대로 저장하면 안 된다. ② MD5·SHA-1 같은 빠른 해시로 저장하면 안 된다. 이런 해시는 초당 수십억 번 대입하는 무차별 공격에 순식간에 뚫린다. 비밀번호 저장에는 일부러 느리게 설계된 bcrypt·argon2·scrypt 만 쓴다. ③ SECRET_KEY 나 해시를 코드·깃 저장소에 박지 말 것 — 반드시 환경변수로 뺀다.

3. 로그인 → JWT 발급

비밀번호 검증이 통과하면, 서버는 사용자에게 "너는 인증됐다"는 증표를 발급한다. 그 증표가 JWT(JSON Web Token) 다. 한 번 로그인하면 토큰을 받고, 이후 요청마다 그 토큰을 들고 오면 서버는 매번 비밀번호를 묻지 않아도 "아, 아까 그 사람이군" 하고 알아본다.

JWT 는 점(.) 으로 나뉜 세 토막의 문자열이다 — 헤더·페이로드·서명. 핵심은 마지막 서명 인데, 서버만 아는 SECRET_KEY 로 만들어진다. 누가 토큰 내용을 위조해도 서명이 안 맞으면 서버가 즉시 가짜라고 판별한다. FastAPI 는 로그인 폼을 받는 표준 도구 OAuth2PasswordRequestForm 을 제공한다.

# auth.py import os from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from jose import jwt from security import verify_password from db import get_user_by_email # 9편 범위 밖, DB 조회 함수라고 가정 SECRET_KEY = os.environ["SECRET_KEY"] # 환경변수에서만! 코드에 박지 않는다 ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 router = APIRouter() @router.post("/token") def login(form: OAuth2PasswordRequestForm = Depends()): user = get_user_by_email(form.username) # OAuth2 표준상 필드명이 username if not user or not verify_password(form.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="이메일 또는 비밀번호가 올바르지 않습니다", headers={"WWW-Authenticate": "Bearer"}, ) expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) token = jwt.encode( {"sub": user.email, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM, ) return {"access_token": token, "token_type": "bearer"}

한 흐름으로 읽으면 — ① OAuth2PasswordRequestForm 이 폼에서 username·password 를 꺼내 준다(OAuth2 규격이라 이메일을 받아도 필드명은 username 이다). ② DB 에서 사용자를 찾고 verify_password 로 비밀번호를 검증한다. ③ 틀리면 401 을 던진다. ④ 맞으면 jwt.encode 로 토큰을 만든다. 페이로드의 sub 는 "이 토큰의 주인"(보통 사용자 식별자), exp만료 시각 이다.

exp(만료)가 중요한가 — 토큰이 영원히 유효하면, 한 번 새어 나간 토큰으로 영원히 로그인된다. 30분 정도의 짧은 수명을 두면 탈취돼도 피해 시간이 제한된다. 응답의 {"access_token": ..., "token_type": "bearer"} 형태도 OAuth2 표준 이라, 클라이언트가 받아서 그대로 쓰기 좋다.

4. 보호된 라우트 — 토큰이 있어야 들어온다

이제 마지막 조각. 발급한 토큰을 매 요청에서 검사해서, 유효한 토큰을 가진 사람만 통과시키는 라우트를 만든다. 여기서 5편에서 배운 Depends 가 빛난다. "현재 사용자를 알아내는" 의존성을 한 번 만들어 두고, 보호가 필요한 모든 라우트에 끼워 넣으면 된다.

# deps.py from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError from auth import SECRET_KEY, ALGORITHM from db import get_user_by_email oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") def get_current_user(token: str = Depends(oauth2_scheme)): credentials_error = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="자격 증명을 확인할 수 없습니다", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) email = payload.get("sub") if email is None: raise credentials_error except JWTError: # 서명 위조·만료(exp) 모두 여기서 잡힌다 raise credentials_error user = get_user_by_email(email) if user is None: raise credentials_error return user

OAuth2PasswordBearer(tokenUrl="token") 가 두 가지를 한다 — 요청 헤더의 Authorization: Bearer <토큰> 에서 토큰을 뽑아 주고, /docs 화면에 로그인 버튼을 자동으로 만들어 준다. jwt.decode 는 서명을 검증하고 만료를 확인하는데, 둘 중 하나라도 어긋나면 JWTError 를 던진다 — 그래서 위조 토큰이든 만료 토큰이든 모두 401 로 떨어진다. 이제 보호된 라우트는 한 줄짜리다.

# main.py from fastapi import FastAPI, Depends from deps import get_current_user import auth app = FastAPI() app.include_router(auth.router) # /token (로그인) 등록 @app.get("/me") def read_me(user = Depends(get_current_user)): # 여기 들어왔다는 건 토큰 검증을 이미 통과했다는 뜻 return {"email": user.email}

이게 전부다. /me 함수 시그니처에 user = Depends(get_current_user) 한 줄을 끼운 것만으로, FastAPI 는 함수 본문을 실행하기 전에 토큰을 검사한다. 토큰이 없거나 위조됐거나 만료됐으면 함수는 아예 호출되지 않고 곧장 401 이 나간다. 보호가 필요한 다른 라우트도 같은 의존성 한 줄만 붙이면 된다 — 인증 로직을 라우트마다 베껴 쓸 필요가 없다.

응용은 자연스럽게 따라온다. 로그인한 사용자만 자기 글을 수정하게 하려면 get_current_user 가 돌려준 user 와 글 작성자를 비교하면 되고(여기서부터가 인가다), 관리자 전용 라우트는 user.is_admin 을 조건문 하나로 검사하면 된다. 단단한 인증 위에서 인가는 이렇게 가벼워진다.

요약

9편을 한 호흡에 정리하면 — 인증은 "너 누구냐", 인가는 "너 이거 해도 되냐"다. 비밀번호는 절대 평문이나 MD5 로 저장하지 않고 passlib + bcrypt 로 해싱한다. 로그인이 통과하면 OAuth2PasswordRequestForm 으로 폼을 받아 검증하고, python-josejwt.encodesub·exp 를 담은 JWT 를 환경변수 SECRET_KEY 로 서명해 발급한다. 보호된 라우트는 OAuth2PasswordBearer 로 토큰을 뽑고 get_current_user 의존성에서 jwt.decode 로 검증해, 위조·만료 토큰을 모두 401 로 막는다. 인증의 뼈대가 이 세 단계 — 해싱 · 발급 · 검증 — 안에 다 들어 있다.

다음 편 예고 — FastAPI 테스트

지금까지 만든 API 가 정말 의도대로 동작하는지, TestClient 와 pytest 로 자동 검증하는 법을 배운다.

© 2026 주나이테크(주) @JUNAITECH