데이터베이스를 붙였으니 이제 사용자별 데이터를 보호해야 합니다. OAuth2는 토큰을 발급하고 검증하는 절차를 정리한 공개 규칙 모음입니다. JWT(JSON Web Token)는 그 규칙 안에서 자주 쓰이는 서명된 문자열 형식입니다. FastAPI는 OAuth2 기본 흐름을 품고 있으며, OAuth2PasswordBearer 의존성을 통해 토큰 흐름을 구현할 수 있습니다.
준비: 인증을 쉬운 말로 이해하기
- 로그인은 "비밀번호를 확인하는 절차", 토큰은 "확인 완료 도장"입니다.
- 클라이언트는
/auth/token같은 경로에서 도장을 받고, 이후 요청 헤더에 붙여 보냅니다. - 서버는 도장을 확인해 "이 학생은 누구인지"를 알아낸 다음에만 데이터를 보여 줍니다.
이 한 문장 흐름만 기억하면 아래 코드가 훨씬 쉽게 읽힙니다.
이번 글에서 새로 나오는 용어
- OAuth2: 로그인 후 토큰을 발급·검증하는 절차를 표준화한 규칙 모음으로, 이번 글의 인증 흐름 뼈대를 제공합니다.
- JWT: 서명된 JSON 문자열 형태의 토큰으로, 사용자 정보와 만료 시간을 담아 서버가 위조 여부를 빠르게 확인할 수 있습니다.
- OAuth2PasswordBearer: FastAPI가 OAuth2 Password 흐름을 쉽게 구현하도록 도와주는 의존성으로,
/auth/token경로와 연결됩니다. - secret key: JWT를 서명하고 검증할 때 사용하는 비밀 값으로, 유출되면 누구나 가짜 토큰을 만들 수 있어
.env에 꼭 숨겨야 합니다.
학습 메모
- 소요 시간: 60분
- 준비물: 9편 SQLModel 예제, 비밀번호 해시와 시크릿 키의 기본 개념
- 학습 목표:
/auth/token을 만들어 JWT를 발급하고 보호된 라우터에서 토큰을 검증하기
이 글에서 할 것
- 토큰 기반 인증이 왜 필요한지 흐름으로 이해합니다.
- OAuth2 Password 방식으로 토큰을 발급하는 코드를 작성합니다.
- 보호된 라우터에서 토큰을 읽고 사용자 정보를 확인합니다.
핵심 개념
OAuth2는 로그인 뒤에 토큰을 발급하고 검증하는 절차를 정리한 표준입니다. JWT(JSON Web Token, 서명된 JSON 문자열)는 OAuth2에서 자주 쓰는 토큰 포맷입니다. Bearer 토큰은 "이 토큰을 가진 사람이 접근 권한을 가진다"고 약속하는 방식입니다. Authorization 헤더는 이런 인증 정보를 요청에 함께 보내는 자리입니다. 낯선 용어가 많지만, 결국 "로그인 → 토큰 발급 → 토큰을 들고 다른 요청" 흐름을 반복한다고 이해하면 충분합니다.
왜 토큰이 필요한가
사용자별 데이터를 구분하려면 매 요청마다 신분증 역할을 하는 무언가가 있어야 합니다. 쿠키, 세션, 토큰 방식이 모두 그 역할을 합니다. FastAPI에서는 JWT 같은 토큰 방식을 쓰면 백엔드 서버가 상태를 들고 있지 않아도 됩니다. 그래서 모바일·웹·CLI가 같은 토큰을 공유하며 호출하기 좋고, HTTP 헤더만으로 검증할 수 있어 구조가 단순합니다.
최소 로그인 예제
토큰 흐름을 익히는 가장 빠른 방법은 로그인부터 보호된 라우터까지 한 번에 이어 붙여 보는 것입니다. 아래 예제는 사용자 한 명을 DB에서 읽고 토큰을 발급한 뒤, /me 엔드포인트에서 토큰을 검증해 현재 사용자를 돌려줍니다.
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
from app.db.session import Session, get_session
from app.db.models import User
from app.api.deps import get_current_user
from sqlmodel import select
router = APIRouter(prefix="/auth", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def authenticate_user(session: Session, username: str, password: str) -> User | None:
user = session.exec(select(User).where(User.email == username)).first()
if not user or not pwd_context.verify(password, user.hashed_password):
return None
return user
def create_access_token(*, subject: str, expires_minutes: int = 30) -> str:
payload = {"sub": subject, "exp": datetime.utcnow() + timedelta(minutes=expires_minutes)}
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
@router.post("/token")
def login(
form: OAuth2PasswordRequestForm = Depends(),
session: Session = Depends(get_session),
):
user = authenticate_user(session, form.username, form.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
token = create_access_token(subject=user.email)
return {"access_token": token, "token_type": "bearer", "expires_in": 1800}
@router.get("/me")
def read_me(current_user: User = Depends(get_current_user)):
return {"email": current_user.email, "id": current_user.id}
세션·모델·시크릿 키는 앞선 편에서 만든 구성품을 그대로 사용하면 됩니다. 중요한 점은 로그인부터 인증된 엔드포인트까지 한 파일에서 먼저 검증해 보는 것입니다. 흐름이 확인되면 이후에 모듈을 쪼개도 헷갈리지 않습니다.
참고로 OAuth2 Password 방식은 내부 서비스나 학습용에 적합합니다. 공개 서비스에서 소셜 로그인이나 외부 인증 서버를 붙일 계획이라면 Authorization Code + PKCE 흐름도 함께 검토하세요.
요청·응답 한 눈에 보기
Swagger UI(/docs)를 열면 Authorize 버튼과 /auth/token 폼이 자동으로 나타납니다. 아래 예시는 같은 내용을 curl로 표현한 것입니다.
요청
POST /auth/token
Content-Type: application/x-www-form-urlencoded
[email protected]&password=correct-horse
응답
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 1800
}
Swagger UI에서는 이 응답을 받은 뒤 자동으로 토큰을 저장하고, 이후 보호된 라우터를 호출할 때 Authorization 헤더에 붙여 줍니다. 문서 화면의 "Try it out" 버튼을 눌러 로그인 → /me 호출을 바로 재현해 보세요.
(선택) 토큰 흐름 한눈에 보기
요청이 어디에서 인증되고, 토큰이 어디에 저장되며, 보호된 엔드포인트가 어떤 순서로 동작하는지 시퀀스를 그려 두면 구성 요소를 빠르게 점검할 수 있습니다. 용어가 낯설다면 이 섹션은 다음에 돌아와도 괜찮습니다.
(선택) 기본 코드 골격 정리
from datetime import datetime, timedelta
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
SECRET_KEY = "change-me"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
pwd_context = CryptContext(schemes=["bcrypt"])
router = APIRouter(prefix="/auth", tags=["auth"])
def verify_password(plain, hashed):
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@router.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends(), session: Session = Depends(get_session)):
user = authenticate_user(session, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
token = create_access_token({"sub": user.email})
return {"access_token": token, "token_type": "bearer"}
authenticate_user는 데이터베이스에서 사용자 정보를 조회하고 암호를 비교하는 함수입니다. OAuth2PasswordRequestForm은 username, password 필드를 가진 application/x-www-form-urlencoded 요청을 자동으로 파싱합니다. 이미 위 최소 예제로 전체 흐름을 확인했으니, 여기서는 각 함수가 어디에 놓이는지 정리만 해도 충분합니다.
여기서 JWT는 토큰 안에 사용자 식별 정보와 만료 시간 같은 데이터를 담고, 서버가 서명(signature)으로 위조 여부를 검사하는 방식입니다. 그래서 토큰을 읽을 때는 서명이 맞는지, 만료 시간이 지났는지, 비밀키가 안전하게 관리되는지를 함께 봐야 합니다.
보호된 엔드포인트 작성
def get_current_user(
token: str = Depends(oauth2_scheme),
session: Session = Depends(get_session),
):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
user = session.exec(select(User).where(User.email == payload["sub"])).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
@tasks_router.post("", response_model=Task)
def create_task(
task: TaskCreate,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
record = Task(**task.model_dump(), owner_id=user.id)
session.add(record)
session.commit()
session.refresh(record)
return record
oauth2_scheme는 헤더에서 토큰을 읽고 없으면 401을 반환합니다. 그 이후 로직은 우리가 직접 구현해야 합니다.
uv와 인증 흐름 테스트
uv run fastapi dev app/main.py로 서버를 실행하고, Swagger UI에서 Authorize 버튼을 클릭하면 /auth/token 흐름을 바로 확인할 수 있습니다. 토큰을 발급받은 뒤에는 해당 토큰이 Authorization 헤더에 자동으로 붙습니다.
:::terminal{title="curl로 토큰 발급 후 사용하기", showFinalPrompt="false"}
[
{ cmd: "curl -X POST -d 'username=alice&password=secret' -H 'Content-Type: application/x-www-form-urlencoded' http://127.0.0.1:8000/auth/token", output: "{\"access_token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\"token_type\":\"bearer\"}", delay: 600 },
{ cmd: "TOKEN=eyJhbGc...; curl -H \"Authorization: Bearer $TOKEN\" http://127.0.0.1:8000/tasks", output: "[{\"id\":1,\"title\":\"Submit report\"}]", delay: 400 }
]
:::
왜 중요한가
토큰이 없으면 누구나 아무 API나 호출할 수 있고, 사용자마다 데이터를 분리할 방법이 없습니다. JWT 기반 인증을 붙이면 "로그인한 사용자만 자신의 데이터에 접근한다"는 규칙을 코드로 강제할 수 있습니다. 또 Swagger UI에서 바로 토큰 흐름을 확인할 수 있어 팀원에게 인증 절차를 설명하기도 쉽습니다.
실무 시 고려 사항
- 반드시
.env파일이나 시크릿 매니저에SECRET_KEY를 보관하세요. - 리프레시 토큰과 블랙리스트 정책을 계획해 만료 시간을 짧게 유지하되 UX를 보완합니다.
- 프런트엔드/모바일 클라이언트가 있다면 HTTPS 환경에서만 토큰을 주고받도록 강제합니다.
- 조직 내 인증 서버가 이미 있다면 OAuth2 클라이언트 자격으로 FastAPI 서비스가 토큰을 검증하도록 구성합니다.
실습
- 따라 하기:
/auth/token엔드포인트와get_current_user의존성을 구현해 Swagger UI의 Authorize 버튼으로 로그인해 본다. - 확장하기: 토큰에 역할 정보를 추가하고 특정 라우터에서만 허용되도록 조건을 걸어 본다.
- 디버깅: 잘못된 토큰이나 만료된 토큰을 보냈을 때 401 응답과 로그 메시지가 명확한지 확인한다.
- 완료 기준: JWT 발급/검증 흐름이 하나의 curl 스크립트로 재현 가능하고, 보호된 엔드포인트가 인증 없이는 401을 반환한다.
마무리
토큰 기반 인증을 직접 구현해 보면 보안 흐름이 눈에 보입니다. JWT 발급과 검증을 모두 이해한 상태라면 다음 단계에서 배포 설정을 다룰 때도 안전한 기본기를 유지할 수 있습니다. 다음 11편에서는 인증된 애플리케이션을 실제 환경에 배포하기 위한 설정, 환경 변수 관리, 프로세스 실행 전략을 다룹니다.
💬 댓글
이 글에 대한 의견을 남겨주세요