웹 서비스 보안의 핵심인 JWT(JSON Web Token) 인증을 FastAPI 백엔드와 SvelteKit 프론트엔드에서 완벽하게 구현하는 방법을 정리했습니다. Access Token과 Refresh Token을 활용한 이중 토큰 전략부터 httpOnly 쿠키를 통한 XSS 방어, 자동 토큰 갱신까지 실무에서 바로 사용할 수 있는 코드와 함께 설명합니다.

JWT란 무엇인가?
JWT(JSON Web Token)는 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 컴팩트하고 독립적인 방식을 정의하는 개방형 표준(RFC 7519)입니다. 서명이 되어 있어 정보를 신뢰할 수 있으며, 비밀 키(HMAC 알고리즘)나 RSA/ECDSA를 사용한 공개/개인 키 쌍으로 서명할 수 있습니다.
JWT는 세 부분으로 구성됩니다: Header(알고리즘과 토큰 타입), Payload(클레임), Signature(서명). 이 세 부분은 점(.)으로 구분되어 xxxxx.yyyyy.zzzzz 형태를 이룹니다.
Access Token + Refresh Token 전략
보안과 사용자 경험을 동시에 잡으려면 두 가지 토큰을 함께 사용하는 전략이 필수입니다. Access Token은 짧은 만료 시간(15~30분)을 가지며 API 요청 인증에 사용하고, Refresh Token은 긴 만료 시간(7~30일)으로 새 Access Token 발급에만 사용합니다.
FastAPI JWT 설정
# requirements.txt
fastapi==0.115.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
# auth/config.py
from datetime import timedelta
SECRET_KEY = "your-super-secret-key-change-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
토큰 생성 및 검증 유틸리티
# auth/jwt.py
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES, REFRESH_TOKEN_EXPIRE_DAYS
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str, token_type: str = "access") -> Optional[dict]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != token_type:
return None
return payload
except JWTError:
return None
FastAPI 인증 라우터 구현
로그인, 토큰 갱신, 로그아웃 엔드포인트를 구현합니다. 보안을 위해 Refresh Token은 httpOnly 쿠키에 저장하고, Access Token만 JSON 응답으로 반환합니다.
# auth/router.py
from fastapi import APIRouter, Depends, HTTPException, Response, Cookie
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from .jwt import verify_password, create_access_token, create_refresh_token, verify_token
from ..database import get_db
from ..models import User
from sqlalchemy import select
router = APIRouter(prefix="/auth", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
@router.post("/login")
async def login(
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db)
):
# 사용자 조회
result = await db.execute(select(User).where(User.email == form_data.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다")
# 토큰 생성
access_token = create_access_token(data={"sub": str(user.id), "email": user.email})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
# Refresh Token을 httpOnly 쿠키에 저장
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True, # HTTPS 환경에서만
samesite="lax",
max_age=7 * 24 * 60 * 60 # 7일
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/refresh")
async def refresh_token(
response: Response,
refresh_token: Optional[str] = Cookie(default=None),
db: AsyncSession = Depends(get_db)
):
if not refresh_token:
raise HTTPException(status_code=401, detail="Refresh token이 없습니다")
payload = verify_token(refresh_token, token_type="refresh")
if not payload:
raise HTTPException(status_code=401, detail="유효하지 않은 refresh token입니다")
user_id = payload.get("sub")
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="사용자를 찾을 수 없습니다")
# 새 토큰 발급
new_access_token = create_access_token(data={"sub": str(user.id), "email": user.email})
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
response.set_cookie(
key="refresh_token",
value=new_refresh_token,
httponly=True,
secure=True,
samesite="lax",
max_age=7 * 24 * 60 * 60
)
return {"access_token": new_access_token, "token_type": "bearer"}
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie("refresh_token")
return {"message": "로그아웃 되었습니다"}
# 현재 사용자 의존성
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
):
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="유효하지 않은 토큰입니다")
user_id = payload.get("sub")
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="사용자를 찾을 수 없습니다")
return user
SvelteKit 인증 스토어 구현
SvelteKit 클라이언트 측에서는 Svelte 5 Runes를 활용한 반응형 인증 상태 관리와 자동 토큰 갱신 인터셉터를 구현합니다.
// src/lib/stores/auth.svelte.ts
import { goto } from '$app/navigation';
interface AuthState {
accessToken: string | null;
user: { id: number; email: string } | null;
isLoggedIn: boolean;
}
function createAuthStore() {
let state = $state<AuthState>({
accessToken: null,
user: null,
isLoggedIn: false
});
async function login(email: string, password: string) {
const formData = new FormData();
formData.append('username', email);
formData.append('password', password);
const res = await fetch('/api/auth/login', {
method: 'POST',
body: formData,
credentials: 'include' // 쿠키 포함
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || '로그인 실패');
}
const data = await res.json();
state.accessToken = data.access_token;
state.isLoggedIn = true;
// 사용자 정보 조회
await fetchUserInfo();
goto('/dashboard');
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
state.accessToken = null;
state.user = null;
state.isLoggedIn = false;
goto('/login');
}
async function refreshAccessToken(): Promise<string | null> {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (!res.ok) {
await logout();
return null;
}
const data = await res.json();
state.accessToken = data.access_token;
return data.access_token;
} catch {
await logout();
return null;
}
}
// 인증된 fetch 래퍼 — 401 시 자동 토큰 갱신
async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
if (!state.accessToken) {
const token = await refreshAccessToken();
if (!token) throw new Error('인증이 필요합니다');
}
const res = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${state.accessToken}`
}
});
if (res.status === 401) {
const newToken = await refreshAccessToken();
if (!newToken) throw new Error('세션이 만료되었습니다');
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`
}
});
}
return res;
}
async function fetchUserInfo() {
const res = await authFetch('/api/users/me');
if (res.ok) {
state.user = await res.json();
}
}
return {
get accessToken() { return state.accessToken; },
get user() { return state.user; },
get isLoggedIn() { return state.isLoggedIn; },
login,
logout,
refreshAccessToken,
authFetch
};
}
export const auth = createAuthStore();
SvelteKit 로그인 페이지
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
import { auth } from '$lib/stores/auth.svelte';
let email = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function handleLogin() {
loading = true;
error = '';
try {
await auth.login(email, password);
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
}
</script>
<form on:submit|preventDefault={handleLogin} class="max-w-md mx-auto mt-20 p-8 bg-white rounded-xl shadow">
<h1 class="text-2xl font-bold mb-6">로그인</h1>
{#if error}
<div class="bg-red-50 text-red-600 p-3 rounded mb-4">{error}</div>
{/if}
<div class="mb-4">
<label class="block text-sm font-medium mb-1">이메일</label>
<input bind:value={email} type="email" required
class="w-full border rounded px-3 py-2 focus:ring-2 focus:ring-blue-500" />
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-1">비밀번호</label>
<input bind:value={password} type="password" required
class="w-full border rounded px-3 py-2 focus:ring-2 focus:ring-blue-500" />
</div>
<button type="submit" disabled={loading}
class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50">
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
보안 고려사항 총정리
JWT 인증 구현 시 반드시 챙겨야 할 보안 포인트입니다:
- httpOnly 쿠키: Refresh Token을 httpOnly 쿠키에 저장하면 JavaScript에서 접근이 불가능해 XSS 공격으로부터 보호됩니다.
- HTTPS 필수:
secure=True쿠키 옵션으로 HTTPS 환경에서만 전송되도록 강제합니다. - 짧은 Access Token 만료: 15~30분으로 설정해 탈취 시 피해를 최소화합니다.
- Refresh Token 단일 사용: 갱신 시 기존 Refresh Token을 무효화하고 새 토큰을 발급하면 보안이 더 강해집니다(Token Rotation).
- SECRET_KEY 관리: 환경변수로 관리하고 절대 코드에 하드코딩하지 않습니다.
SvelteKit 서버사이드 인증 처리
SvelteKit의 hooks.server.ts를 활용하면 서버사이드에서 인증 상태를 확인하고 미인증 사용자를 리다이렉트할 수 있습니다.
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
const protectedRoutes = ['/dashboard', '/profile', '/settings'];
export const handle: Handle = async ({ event, resolve }) => {
const refreshToken = event.cookies.get('refresh_token');
const isProtected = protectedRoutes.some(route => event.url.pathname.startsWith(route));
if (isProtected && !refreshToken) {
throw redirect(302, '/login');
}
// Access Token 갱신 시도 (서버사이드)
if (refreshToken && !event.locals.user) {
try {
const res = await fetch(`${process.env.API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Cookie': `refresh_token=${refreshToken}` }
});
if (res.ok) {
const data = await res.json();
event.locals.accessToken = data.access_token;
}
} catch { /* 갱신 실패 시 무시 */ }
}
return resolve(event);
};
코드벤터는 글로벌 협력 네트워크를 통해 FastAPI, SvelteKit, 보안 아키텍처 등 최신 웹 기술 분야의 실전 노하우를 지속적으로 공유합니다. JWT 인증은 단순히 코드를 복사하는 것보다 토큰의 생명주기와 보안 원칙을 이해하고 구현하는 것이 중요합니다. 이 가이드가 안전하고 견고한 인증 시스템을 구축하는 데 도움이 되길 바랍니다.



