JWT 인증과 TOTP 2FA 구현
JWT 3종 토큰 전략, pyotp TOTP 2FA, Next.js Edge 미들웨어 인증을 아우르는 실전 보안 구현
토큰을 용도별로 3종으로 나누고 발급(FastAPI)과 검증(Next.js Edge)을 분리하면, 두 계층이 시크릿만 공유해도 인증이 성립합니다.
인증 아키텍처 전체 플로우
FastAPI 백엔드는 비밀번호와 시간 기반 일회용 비밀번호(TOTP)를 검증한 뒤 토큰을 발급합니다. Next.js 미들웨어는 Access Token을 검증하고, Refresh Token은 FastAPI만 읽는 쿠키로 유지합니다.
시스템 구성
인증 플로우 4단계
전체 인증 과정은 다음 4단계로 이루어집니다.
1단계: 로그인 (비밀번호 검증)
POST /api/auth/login { username, password }
→ 200 { requires_2fa: true, temp_token: "..." }2단계: 2FA 검증
POST /api/auth/verify-2fa { temp_token, otp_code }
→ 200 + Set-Cookie: access_token, refresh_token3단계: API 요청
GET /api/account/balance
Cookie: access_token=<JWT>
→ 200 { ... }4단계: 토큰 갱신
POST /api/auth/refresh (refresh_token HttpOnly Cookie)
→ 200 + Set-Cookie: access_token, rotated refresh_tokenJWT 3종 토큰 전략
단일 토큰 대신 용도별로 분리된 3종 토큰을 사용합니다. 각 토큰은 type 클레임으로 구분되며, 만료 시간과 저장 위치가 다릅니다.
| 토큰 | 만료 | 저장 위치 | 용도 |
|---|---|---|---|
| Access Token | 30분 | HttpOnly Cookie (/) | API·페이지 요청 인증 |
| Refresh Token | 7일 | HttpOnly Cookie | Access Token 갱신 |
| Temp Token | 5분 | 메모리 | 2FA 대기 중 임시 인증 |
이 예제는 Next.js와 FastAPI를 같은 출처로 프록시하고 두 토큰을 HttpOnly Cookie에 둡니다. Access Token은 전체 경로에, Refresh Token은 갱신 경로에만 전송합니다.
의존성 설치
# pyproject.toml
[project]
dependencies = [
"fastapi>=0.115",
"python-jose[cryptography]>=3.3", # JWT
"passlib[bcrypt]>=1.7", # 비밀번호 해싱
"pyotp>=2.9", # TOTP 2FA
"qrcode[pil]>=7.4", # QR 코드 생성
]python-jose는 JWK·JWE까지 다루지만 유지보수가 활발하지 않고 CVE 이력이 있습니다. JWT 서명·검증만 필요하면 유지보수가 꾸준한 PyJWT로 대체해도 됩니다. 이 글의 jwt.encode/jwt.decode 호출은 두 라이브러리 모두 거의 같은 형태로 옮길 수 있습니다.
인증 설정
# app/auth/config.py
from pydantic_settings import BaseSettings
class AuthSettings(BaseSettings):
# JWT
jwt_secret: str # openssl rand -hex 32
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30 # 30분
refresh_token_expire_days: int = 7 # 7일
temp_token_expire_minutes: int = 5 # 2FA 대기 토큰 5분
# TOTP
totp_issuer: str = "PersonalTrader"
totp_interval: int = 30 # 30초 주기
# 보안
max_login_attempts: int = 5 # 5회 실패 시 잠금
lockout_minutes: int = 15 # 15분 잠금
class Config:
env_prefix = "AUTH_"비밀번호 해싱
평문 비밀번호를 절대 저장하지 않습니다. bcrypt를 사용해 단방향 해싱합니다.
# app/auth/password.py
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)JWT 토큰 생성 및 검증
3종 토큰은 모두 같은 시크릿 키로 서명하되, type 클레임으로 용도를 구분합니다.
# app/auth/jwt.py
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from jose import JWTError, jwt
from app.auth.config import AuthSettings
settings = AuthSettings()
def create_access_token(
sub: str,
extra: dict | None = None,
expires_delta: timedelta | None = None,
) -> str:
"""Access Token 생성 (기본 30분)."""
now = datetime.now(timezone.utc)
expire = now + (expires_delta or timedelta(
minutes=settings.access_token_expire_minutes
))
payload = {
"sub": sub,
"exp": expire,
"iat": now,
"type": "access",
}
if extra:
payload.update(extra)
return jwt.encode(
payload, settings.jwt_secret, algorithm=settings.jwt_algorithm
)
def create_refresh_token(sub: str, family: str | None = None) -> str:
"""Refresh Token 생성 (7일)."""
now = datetime.now(timezone.utc)
expire = now + timedelta(days=settings.refresh_token_expire_days)
payload = {
"sub": sub,
"exp": expire,
"iat": now,
"jti": str(uuid4()),
"family": family or str(uuid4()),
"type": "refresh",
}
return jwt.encode(
payload, settings.jwt_secret, algorithm=settings.jwt_algorithm
)
def create_temp_token(sub: str) -> str:
"""2FA 대기용 임시 토큰 (5분)."""
now = datetime.now(timezone.utc)
expire = now + timedelta(
minutes=settings.temp_token_expire_minutes
)
payload = {
"sub": sub,
"exp": expire,
"iat": now,
"type": "temp_2fa",
}
return jwt.encode(
payload, settings.jwt_secret, algorithm=settings.jwt_algorithm
)
def decode_token(token: str, expected_type: str = "access") -> dict:
"""JWT 토큰 디코딩 및 type 클레임 검증."""
payload = jwt.decode(
token, settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
options={"require_exp": True, "require_iat": True, "require_sub": True},
)
if payload.get("type") != expected_type:
raise ValueError(
f"Expected '{expected_type}', got '{payload.get('type')}'"
)
if not isinstance(payload.get("sub"), str) or not payload["sub"]:
raise ValueError("Token requires a non-empty sub")
if expected_type == "refresh" and not all(
isinstance(payload.get(claim), str) and payload[claim]
for claim in ("jti", "family")
):
raise ValueError("Refresh token requires jti and family")
return payloadFastAPI 의존성 주입
보호가 필요한 라우트에 Depends를 사용해 인증을 적용합니다.
# app/auth/deps.py
from typing import Annotated
from fastapi import Cookie, Depends, HTTPException, status
from jose import JWTError
from app.auth.jwt import decode_token
async def get_current_user(
access_token: Annotated[str | None, Cookie()] = None,
) -> dict:
"""현재 인증된 사용자 반환."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if access_token is None:
raise credentials_exception
try:
payload = decode_token(access_token, expected_type="access")
username: str | None = payload.get("sub")
if username is None:
raise credentials_exception
except (JWTError, ValueError):
raise credentials_exception
return {"username": username}
# 보호 라우트에서 사용
CurrentUser = Annotated[dict, Depends(get_current_user)]pyotp TOTP 구현과 QR 코드
TOTP 동작 원리
TOTP(Time-based One-Time Password)는 공유 비밀키와 현재 시간을 기반으로 일회용 코드를 생성합니다. 서버와 클라이언트(인증 앱)가 동일한 비밀키를 보유하고, 30초 간격으로 같은 6자리 코드를 생성합니다.
pyotp 기본 사용법
import pyotp
# 비밀키 생성 (사용자별 1회)
secret = pyotp.random_base32() # 'JBSWY3DPEHPK3PXP' 형태
# TOTP 객체 생성
totp = pyotp.TOTP(secret)
# 현재 OTP 코드 (6자리)
code = totp.now() # '492039'
# 같은 시간 구간의 반복 호출은 둘 다 True일 수 있음
totp.verify(code)
totp.verify(code)
# QR 코드용 프로비저닝 URI
uri = totp.provisioning_uri(
name="user@example.com",
issuer_name="PersonalTrader",
)
# → 'otpauth://totp/PersonalTrader:user@example.com?secret=...&issuer=PersonalTrader'verify 자체는 같은 코드를 한 번만 허용하지 않습니다. 재전송을 막으려면 수락한 시간 단계(timecode)를 사용자별로 저장하고, 그 값보다 큰 단계만 원자적으로 기록해야 합니다.
2FA 설정 엔드포인트
아래는 사용자 저장소와 연결해야 하는 통합 의사코드입니다. pending_otp_secret은 활성화 전까지 암호화해 저장하고, QR 응답에서는 현재 사용자의 값만 읽어야 합니다.
# app/auth/routes.py
import io
import pyotp
import qrcode
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
router = APIRouter(prefix="/api/auth", tags=["auth"])
class OTPSetupResponse(BaseModel):
otp_secret: str
otp_uri: str
@router.post("/2fa/setup", response_model=OTPSetupResponse)
async def setup_2fa(current_user: CurrentUser):
"""2FA 설정 - OTP 비밀키 및 QR URI 반환."""
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(
name=current_user["username"],
issuer_name="PersonalTrader",
)
# DB에 otp_secret 임시 저장 (활성화 전)
# await user_repo.set_pending_otp_secret(username, secret)
return OTPSetupResponse(otp_secret=secret, otp_uri=uri)
@router.get("/2fa/qrcode")
async def get_2fa_qrcode(current_user: CurrentUser):
"""2FA QR 코드 이미지 반환."""
# DB에서 pending otp_secret 조회
secret = "PENDING_SECRET" # 실제 구현 시 DB 조회
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(
name=current_user["username"],
issuer_name="PersonalTrader",
)
img = qrcode.make(uri)
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return StreamingResponse(buf, media_type="image/png")사용자 플로우:
/2fa/setup호출 →otp_secret과otp_uri수신otp_uri를 QR 코드로 변환하거나/2fa/qrcode엔드포인트에서 이미지 수신- Google Authenticator 등에서 QR 스캔
- 인증 앱에 표시되는 코드로 검증 → 2FA 활성화
로그인 플로우 (비밀번호 + 2FA)
비밀번호와 사용자 조회 부분은 애플리케이션 저장소에 따라 달라지므로 다음 블록은 통합 의사코드입니다. 토큰 상태와 TOTP 재사용 방지는 뒤의 실행 가능한 AuthState 구현을 사용합니다.
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
requires_2fa: bool = False
temp_token: str | None = None
token_type: str = "bearer"
def set_session_cookies(response: Response, subject: str) -> None:
access = create_access_token(subject)
refresh = create_refresh_token(subject)
auth_state.register_refresh(refresh)
response.set_cookie(
"access_token", access,
httponly=True, secure=True, samesite="strict",
max_age=30 * 60, path="/",
)
response.set_cookie(
"refresh_token", refresh,
httponly=True, secure=True, samesite="strict",
max_age=7 * 24 * 3600, path="/api/auth/refresh",
)
@router.post("/login", response_model=LoginResponse)
async def login(req: LoginRequest, response: Response):
"""1단계: 비밀번호 검증."""
# user = await user_repo.get_by_username(req.username)
# if not user or not verify_password(req.password, user.hashed_password):
# raise HTTPException(401, "Incorrect username or password")
# 로그인 시도 횟수 확인 (브루트포스 방지)
# if user.login_attempts >= settings.max_login_attempts:
# raise HTTPException(429, "Account locked. Try again later.")
has_2fa = True # user.otp_enabled
if has_2fa:
temp_token = create_temp_token(sub=req.username)
return LoginResponse(requires_2fa=True, temp_token=temp_token)
set_session_cookies(response, req.username)
return LoginResponse()
class Verify2FARequest(BaseModel):
temp_token: str
otp_code: str
@router.post("/verify-2fa", response_model=LoginResponse)
async def verify_2fa_login(req: Verify2FARequest, response: Response):
"""2단계: TOTP 코드 검증 후 실제 토큰 발급."""
try:
payload = decode_token(req.temp_token, expected_type="temp_2fa")
except Exception:
raise HTTPException(401, "Invalid or expired temp token")
username = payload["sub"]
user = auth_state.load_user(username)
if user is None or not verify_totp_once(
username, user.otp_secret, req.otp_code
):
raise HTTPException(400, "Invalid OTP code")
set_session_cookies(response, username)
return LoginResponse()verify_totp_once는 현재 단계와 앞뒤 한 단계를 비교한 뒤 auth_state.claim_totp_step으로 수락 단계를 기록합니다. 저장소의 조건부 갱신 때문에 동시에 들어온 같은 코드는 하나만 성공합니다.
Refresh Token Rotation과 HttpOnly Cookie
Refresh Token Rotation
Refresh Token에는 토큰 식별자 jti와 계보 식별자 family가 들어갑니다. 갱신 트랜잭션은 기존 jti를 사용 처리하고 같은 계보의 새 토큰을 등록합니다. 이미 사용한 토큰이 들어오면 해당 계보 전체를 폐기합니다.
import hmac
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated
import pyotp
from fastapi import Cookie, HTTPException, Response
from fastapi.responses import JSONResponse
from jose import JWTError
class AuthState:
def __init__(self, path: Path):
self.path = path
with self.connect() as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS refresh_tokens (
jti TEXT PRIMARY KEY,
family TEXT NOT NULL,
subject TEXT NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER,
revoked_at INTEGER
);
CREATE TABLE IF NOT EXISTS totp_steps (
subject TEXT PRIMARY KEY,
last_step INTEGER NOT NULL
);
""")
def connect(self):
return sqlite3.connect(self.path, isolation_level=None)
def register_refresh(self, token: str) -> None:
payload = decode_token(token, expected_type="refresh")
with self.connect() as db:
db.execute(
"INSERT INTO refresh_tokens VALUES (?, ?, ?, ?, NULL, NULL)",
(payload["jti"], payload["family"], payload["sub"], payload["exp"]),
)
def rotate_refresh(self, token: str) -> str:
payload = decode_token(token, expected_type="refresh")
now = int(time.time())
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute(
"SELECT used_at, revoked_at, expires_at FROM refresh_tokens WHERE jti = ?",
(payload["jti"],),
).fetchone()
if row is None or row[0] is not None or row[1] is not None or row[2] <= now:
db.execute(
"UPDATE refresh_tokens SET revoked_at = ? WHERE family = ?",
(now, payload["family"]),
)
db.execute("COMMIT")
raise ValueError("refresh token reuse or invalid state")
db.execute(
"UPDATE refresh_tokens SET used_at = ? WHERE jti = ?",
(now, payload["jti"]),
)
new_token = create_refresh_token(payload["sub"], payload["family"])
new_payload = decode_token(new_token, expected_type="refresh")
db.execute(
"INSERT INTO refresh_tokens VALUES (?, ?, ?, ?, NULL, NULL)",
(new_payload["jti"], new_payload["family"], new_payload["sub"], new_payload["exp"]),
)
return new_token
def claim_totp_step(self, subject: str, step: int) -> bool:
with self.connect() as db:
result = db.execute("""
INSERT INTO totp_steps(subject, last_step) VALUES (?, ?)
ON CONFLICT(subject) DO UPDATE SET last_step = excluded.last_step
WHERE excluded.last_step > totp_steps.last_step
""", (subject, step))
return result.rowcount == 1
auth_state = AuthState(Path("auth-state.sqlite3"))
def verify_totp_once(subject: str, secret: str, code: str) -> bool:
totp = pyotp.TOTP(secret)
current_step = totp.timecode(datetime.now(timezone.utc))
for step in range(current_step - 1, current_step + 2):
if hmac.compare_digest(totp.generate_otp(step), code):
return auth_state.claim_totp_step(subject, step)
return False
@router.post("/refresh")
async def rotate_refresh(
response: Response,
refresh_token: Annotated[str | None, Cookie()] = None,
):
if refresh_token is None:
raise HTTPException(401, "Missing refresh token")
try:
payload = decode_token(refresh_token, expected_type="refresh")
new_refresh_token = auth_state.rotate_refresh(refresh_token)
except (JWTError, ValueError):
error = JSONResponse(
status_code=401,
content={"detail": "Invalid or reused refresh token"},
)
error.delete_cookie("refresh_token", path="/api/auth/refresh")
error.delete_cookie("access_token", path="/")
return error
access_token = create_access_token(payload["sub"])
response.set_cookie(
"refresh_token", new_refresh_token,
httponly=True, secure=True, samesite="strict",
max_age=7 * 24 * 3600, path="/api/auth/refresh",
)
response.set_cookie(
"access_token", access_token,
httponly=True, secure=True, samesite="strict",
max_age=30 * 60, path="/",
)
return {"refreshed": True}HttpOnly Cookie 설정
HttpOnly Cookie는 JavaScript의 토큰 값 읽기를 막지만 XSS 자체를 막지는 않습니다. 주입된 스크립트가 같은 출처 요청을 보낼 수 있으므로 CSP와 출력 인코딩도 함께 적용해야 합니다.
from fastapi.responses import JSONResponse
@router.post("/login-cookie")
async def login_with_cookie(req: LoginRequest):
"""Refresh Token을 HttpOnly Cookie로 설정."""
# ... 인증 로직 ...
access_token = create_access_token(sub=req.username)
refresh_token = create_refresh_token(sub=req.username)
auth_state.register_refresh(refresh_token)
response = JSONResponse(content={"authenticated": True})
response.set_cookie(
key="access_token", value=access_token,
httponly=True, secure=True, samesite="strict",
max_age=30 * 60, path="/",
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True, # JS 접근 차단
secure=True, # HTTPS only
samesite="strict", # CSRF 방지
max_age=7 * 24 * 3600, # 7일
path="/api/auth/refresh", # 갱신 엔드포인트에서만 전송
)
return response쿠키 보안 옵션:
| 옵션 | 값 | 효과 |
|---|---|---|
httponly | True | document.cookie로 접근 불가 (XSS 방어) |
secure | True | HTTPS 연결에서만 쿠키 전송 |
samesite | strict | 다른 사이트에서의 요청 시 쿠키 미전송 (CSRF, Cross-Site Request Forgery 방어) |
path | /api/auth/refresh | 갱신 엔드포인트로만 쿠키 범위 제한 |
Next.js Edge Runtime 미들웨어
Edge Runtime에서의 JWT 검증
이 예제는 Next.js 미들웨어를 Edge Runtime에 배치하므로 Web Crypto 호환 라이브러리인 jose를 사용합니다. Next.js 15.5 이후에는 Node.js Runtime도 안정 지원하므로 배포 런타임에 맞춰 선택할 수 있습니다.
pnpm add jose미들웨어 구현
// middleware.ts (프로젝트 루트)
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET!
);
// 인증 불필요한 경로
const PUBLIC_PATHS = ["/login", "/api/auth"];
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(
(path) => pathname === path || pathname.startsWith(`${path}/`),
);
}
function unauthorized(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === "/api" || pathname.startsWith("/api/")) {
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
}
return NextResponse.redirect(new URL("/login", request.url));
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 공개 경로는 통과
if (isPublicPath(pathname)) {
return NextResponse.next();
}
// 같은 출처의 HttpOnly Cookie에서 Access Token 확인
const token = request.cookies.get("access_token")?.value;
if (!token) {
return unauthorized(request);
}
try {
// JWT 검증 (jose - Edge Runtime 호환)
const { payload } = await jwtVerify(token, JWT_SECRET, {
algorithms: ["HS256"],
requiredClaims: ["sub", "exp", "iat", "type"],
});
if (payload.type !== "access" || typeof payload.sub !== "string" || !payload.sub) {
throw new Error("Invalid access token claims");
}
// 사용자 정보를 요청 헤더에 실어 다운스트림으로 전달
// (response.headers에 넣으면 브라우저로 노출되고 서버 컴포넌트에는 닿지 않는다)
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user", payload.sub as string);
return NextResponse.next({ request: { headers: requestHeaders } });
} catch {
return unauthorized(request);
}
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};클라이언트 자동 토큰 갱신
Access Token 만료 시 Refresh Token Cookie로 갱신하고 원래 요청을 한 번 다시 보냅니다. 이 자동 갱신은 열린 페이지의 API 요청에 적용되며, 만료된 Cookie로 페이지를 직접 열면 로그인 화면으로 이동합니다.
// lib/auth.ts
export async function fetchWithAuth(
url: string,
options: RequestInit = {},
): Promise<Response> {
let response = await fetch(url, { ...options, credentials: "include" });
// 401이면 Refresh Token으로 갱신 시도
if (response.status === 401) {
const refreshResponse = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include", // HttpOnly Cookie 전송
});
if (refreshResponse.ok) {
response = await fetch(url, { ...options, credentials: "include" });
} else {
// Refresh도 실패 → 로그인 페이지로
window.location.href = "/login";
}
}
return response;
}보안 헤더 설정
Next.js 보안 헤더
next.config.ts에서 모든 응답에 보안 헤더를 추가합니다.
// next.config.ts
const securityHeaders = [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
];
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};
export default nextConfig;각 헤더의 역할:
| 헤더 | 효과 |
|---|---|
Strict-Transport-Security | HTTPS 강제 (2년, 서브도메인 포함) |
X-Frame-Options | 클릭재킹 방지 (iframe 삽입 차단) |
X-Content-Type-Options | MIME 스니핑 방지 |
Referrer-Policy | 외부 이동 시 전체 URL 대신 origin만 전송 |
Permissions-Policy | 카메라, 마이크, 위치 정보 API 비활성화 |
Content Security Policy (CSP)
CSP는 실행 가능한 스크립트 출처를 허용 목록으로 제한해 XSS의 영향 범위를 좁힙니다. nonce 기반이면 인라인 스크립트도 서명된 것만 실행을 허용합니다.
Next.js는 프로젝트당 미들웨어를 하나만 둡니다. 아래 CSP 로직은 앞의 JWT 검증과 같은 middleware() 함수 안에서 합쳐야 합니다. nonce를 요청 헤더에 싣고 토큰을 검증한 뒤 최종 응답에 CSP 헤더를 설정합니다.
// middleware.ts (CSP 부분 — JWT 검증 로직과 한 함수로 합쳐 사용)
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID())
.toString("base64");
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
connect-src 'self' wss://jongkwan.dev ws://localhost:*;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
`.replace(/\s{2,}/g, " ").trim();
const response = NextResponse.next();
response.headers.set("Content-Security-Policy", cspHeader);
response.headers.set("x-nonce", nonce);
return response;
}FastAPI CORS 설정
프론트엔드와 백엔드가 다른 도메인(또는 포트)에서 동작하므로 CORS 설정이 필요합니다.
# app/main.py
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://jongkwan.dev",
"http://localhost:3000", # 개발 환경
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
allow_credentials=True와allow_origins에 와일드카드(*)는 동시에 사용할 수 없습니다. 반드시 구체적인 origin을 명시해야 합니다.
Rate Limiting
브루트포스 공격을 방지하기 위해 로그인 엔드포인트에 속도 제한을 적용합니다.
# app/auth/middleware.py
from collections import defaultdict
from datetime import datetime, timezone
from fastapi import Request, HTTPException
login_attempts: dict[str, list[datetime]] = defaultdict(list)
RATE_LIMIT_WINDOW = 900 # 15분
RATE_LIMIT_MAX = 5 # 5회
async def check_login_rate_limit(request: Request):
"""로그인 시도 횟수 제한 (IP 기반)."""
client_ip = request.client.host if request.client else "unknown"
now = datetime.now(timezone.utc)
# 윈도우 밖의 기록 정리
attempts = login_attempts[client_ip]
cutoff = now.timestamp() - RATE_LIMIT_WINDOW
attempts[:] = [a for a in attempts if a.timestamp() > cutoff]
if len(attempts) >= RATE_LIMIT_MAX:
raise HTTPException(
status_code=429,
detail=f"Too many login attempts. "
f"Try again in {RATE_LIMIT_WINDOW // 60} minutes.",
)
attempts.append(now)이 카운터는 프로세스 메모리에 저장되므로 워커나 인스턴스가 여러 개면 각자 따로 집계합니다. 다중 인스턴스 배포에서는 Redis 같은 공유 저장소로 옮겨야 제한이 실제로 동작합니다.
설정 요약:
| 항목 | 값 |
|---|---|
| 최대 시도 횟수 | 15분당 5회 |
| 잠금 시간 | 15분 |
| 제한 기준 | 클라이언트 IP |
CVE-2025-29927 대응
취약점 개요
Next.js에서 x-middleware-subrequest 헤더를 통해 미들웨어를 완전히 우회할 수 있는 심각한 취약점이 발견되었습니다.
영향 버전: Next.js < 15.2.3, < 14.2.25, < 13.5.9
심각도: Critical이 취약점을 악용하면 공격자가 인증 미들웨어를 건너뛰고 보호된 라우트에 직접 접근할 수 있습니다.
대응 방법
최우선: Next.js 업데이트
pnpm add next@latest # 15.2.3 이상임시 방어 (업데이트 전)
// middleware.ts 최상단에 추가
if (request.headers.get("x-middleware-subrequest")) {
return new NextResponse(null, { status: 403 });
}이 임시 방어는 업데이트 전 긴급 조치입니다. 반드시 Next.js를 최신 버전으로 업데이트해야 합니다.
보안 체크리스트
각 항목의 중요도는 외부에 노출되는 서비스 기준이며, 필수 항목은 운영 전에 모두 충족해야 합니다.
| 항목 | 설명 | 중요도 |
|---|---|---|
| JWT Secret | 최소 256비트 (openssl rand -hex 32) | 필수 |
| HTTPS Only | Cloudflare Tunnel이 보장 | 필수 |
| HttpOnly Cookie | Refresh Token의 XSS 방지 | 필수 |
| SameSite=Strict | CSRF 방지 | 필수 |
| Rate Limiting | 로그인 5회/15분 | 필수 |
| HSTS | max-age 2년, preload | 필수 |
| X-Frame-Options | SAMEORIGIN (클릭재킹 방지) | 필수 |
| Token Rotation | Refresh 시 새 토큰 발급 | 권장 |
| TOTP 재사용 방지 | 수락한 timecode의 원자적 갱신 | 필수 |
| CSP | Script-src nonce 기반 | 권장 |
| CVE-2025-29927 | Next.js 15.2.3 이상 | 필수 |
데이터 모델
otp_secret은 TOTP 공유 비밀키를 담고, otp_enabled·otp_verified로 2FA 활성화 단계를 구분합니다.
login_attempts와 locked_until은 brute force 방어용 잠금 상태를 기록합니다.
# app/models/user.py
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.sql import func
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
# 2FA
otp_enabled = Column(Boolean, default=False)
otp_secret = Column(String(32), nullable=True)
otp_verified = Column(Boolean, default=False)
# 보안
login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime(timezone=True), nullable=True)
last_login = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())환경 변수
# JWT
AUTH_JWT_SECRET=<openssl rand -hex 32>
AUTH_JWT_ALGORITHM=HS256
AUTH_ACCESS_TOKEN_EXPIRE_MINUTES=30
AUTH_REFRESH_TOKEN_EXPIRE_DAYS=7
# TOTP
AUTH_TOTP_ISSUER=PersonalTrader
# Next.js
JWT_SECRET=<AUTH_JWT_SECRET과 동일한 값>JWT 시크릿은 반드시 openssl rand -hex 32로 생성한 최소 256비트 랜덤 값을 사용해야 합니다. FastAPI와 Next.js가 동일한 시크릿을 공유해야 양쪽에서 토큰을 검증할 수 있습니다.
정리
이 예제는 Access Token과 Refresh Token을 경로가 다른 HttpOnly Cookie에 둡니다. FastAPI와 Next.js는 같은 출처와 서명 설정을 사용하므로 서버 API와 미들웨어가 같은 Access Token을 검증합니다. 브라우저의 갱신 요청은 본문 토큰을 보내지 않고 제한된 경로의 Refresh Token Cookie를 사용합니다.
TOTP는 마지막 timecode를 원자적으로 기록해 같은 코드의 재사용을 거부합니다. Refresh Token은 SQLite 트랜잭션으로 회전하며 사용된 토큰이 다시 오면 계보 전체를 폐기합니다. 여러 서버가 파일을 공유할 수 없는 배포에서는 같은 스키마와 트랜잭션을 공유 관계형 데이터베이스로 옮겨야 합니다.