JWT 인증 완벽 가이드: 2025년 보안 모범 사례
“로그인을 구현하려는데 세션? 토큰? JWT? 뭘 써야 하지?”
웹 애플리케이션을 개발하다 보면 반드시 마주치는 질문입니다. 특히 JWT(JSON Web Token)는 많이 들어봤지만 막상 제대로 구현하려니 어려운 부분이 많죠.
// 이런 코드 본 적 있나요?
const token = jwt.sign({ userId: user.id }, 'secret-key');
res.json({ token });
// 이게 안전한 걸까요? 토큰 만료는? Refresh는?
이 문서에서는 JWT의 기본 개념부터 2025년 최신 보안 모범 사례까지, Express.js, Next.js 15, React Router 7에서의 실전 구현 방법을 다룹니다.
JWT란 무엇인가?
먼저 JWT가 정확히 무엇인지, 왜 필요한지부터 알아봅시다. 세션 기반 인증과 어떻게 다르고, 어떤 구조로 되어 있는지 이해하면 나머지 내용이 훨씬 쉬워집니다.
JWT(JSON Web Token)는 당사자 간에 정보를 JSON 객체로 안전하게 전송하기 위한 개방형 표준(RFC 7519)입니다.
왜 JWT를 사용하나?
“세션으로도 충분하지 않나?” 이런 생각이 들 수 있습니다. 하지만 분산 서버 환경이나 마이크로서비스 아키텍처에서는 세션 방식이 복잡해집니다. 전통적인 세션 기반 인증과 비교해봅시다.
세션 기반 인증:
1. 사용자 로그인
2. 서버가 세션 생성 → 메모리/DB에 저장
3. 세션 ID를 쿠키로 전송
4. 매 요청마다 서버가 세션 DB 조회
문제점:
- 서버에 상태 저장 필요 (Stateful)
- 분산 서버 환경에서 세션 공유 복잡
- 확장성 문제
JWT 기반 인증:
1. 사용자 로그인
2. 서버가 JWT 생성 (서명만 하고 저장 안 함)
3. JWT를 클라이언트에 전송
4. 매 요청마다 서버가 JWT 서명만 검증
장점:
- 서버에 상태 저장 불필요 (Stateless)
- 분산 서버 환경에 적합
- 마이크로서비스 아키텍처에 유리
JWT의 구조
JWT를 처음 보면 의미 없는 문자열처럼 보이지만, 실제로는 명확한 구조가 있습니다. 이 구조를 이해하면 JWT가 어떻게 동작하는지 직관적으로 알 수 있습니다.
JWT는 점(.)으로 구분된 세 부분으로 이루어져 있습니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header.Payload.Signature
1. Header (헤더)
{
"alg": "HS256",
"typ": "JWT"
}
alg: 서명 알고리즘 (HS256, RS256, ES256, EdDSA 등)typ: 토큰 타입 (JWT)
2. Payload (페이로드)
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622
}
사용자 정보와 메타데이터를 담습니다. 일반적인 클레임(Claim):
sub(subject): 토큰 주체 (사용자 ID)iat(issued at): 토큰 발급 시간exp(expiration): 토큰 만료 시간jti(JWT ID): 토큰 고유 식별자 (취소 시 사용)
중요: Payload는 단순히 Base64로 인코딩되어 있을 뿐 암호화되지 않습니다. 누구나 디코딩할 수 있으므로 민감한 정보를 넣으면 안 됩니다.
3. Signature (서명)
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
헤더와 페이로드를 비밀 키로 서명한 값입니다. 이를 통해:
- 토큰이 변조되지 않았음을 검증
- 토큰이 신뢰할 수 있는 발급자로부터 왔음을 확인
JWT 보안 모범 사례
최신 보안 권장사항을 반영한 JWT 사용법입니다.
1. 알고리즘 선택
어떤 알고리즘을 사용하느냐에 따라 보안 수준이 크게 달라집니다. 대칭키 방식(HS256)은 간단하지만 키 노출 시 위험하고, 비대칭키 방식(ES256, EdDSA)은 더 안전합니다.
// ❌ 나쁜 예: 대칭키 알고리즘 (HS256)
// - 서명과 검증에 같은 키 사용
// - 키가 노출되면 누구나 토큰 생성 가능
const token = jwt.sign(payload, 'secret-key', {
algorithm: 'HS256'
});
// ✅ 좋은 예: 비대칭키 알고리즘 (ES256 또는 EdDSA)
// - 개인키로 서명, 공개키로 검증
// - 공개키가 노출되어도 토큰 생성 불가
const fs = require('fs');
const privateKey = fs.readFileSync('private-key.pem');
const token = jwt.sign(payload, privateKey, {
algorithm: 'ES256', // 또는 'EdDSA'
expiresIn: '15m'
});
// 검증 시
const publicKey = fs.readFileSync('public-key.pem');
const decoded = jwt.verify(token, publicKey, {
algorithms: ['ES256']
});
2025년 권장 알고리즘 (현재 표준):
- EdDSA (Ed25519): 최신, 빠른 성능, 작은 키 크기
- ES256 (ECDSA P-256): 작은 키 크기, 우수한 보안
- RS256 (RSA): 널리 사용됨, 큰 키 필요 (최소 2048비트)
참고 - 양자 컴퓨터 내성: 위 세 알고리즘 모두 현재 널리 사용되는 강력한 암호화 방식이지만, 양자 컴퓨터 공격에는 취약합니다. EdDSA와 ES256은 타원 곡선 이산 로그 문제(ECDLP)에 기반하고, RS256은 정수 인수분해 문제에 기반하므로, Shor 알고리즘을 실행할 수 있는 충분히 강력한 양자 컴퓨터가 등장하면 모두 안전하지 않습니다.
양자 내성 암호(Post-Quantum Cryptography)는 NIST에서 2024년 8월 첫 3개 표준(FIPS 203, 204, 205)을 발표했지만, 아직 JWT에서는 표준화되지 않았습니다. 다만 2025년 현재 상용 양자 컴퓨터는 이러한 암호를 깰 수 있는 수준에 도달하지 않았으므로, 위 알고리즘들은 여전히 안전하게 사용할 수 있습니다.
2. Access Token 만료 시간
Access Token이 탈취되면 공격자가 사용자인 척 할 수 있습니다. 만료 시간을 짧게 설정하면 피해를 최소화할 수 있습니다.
// ❌ 나쁜 예: 긴 만료 시간
const accessToken = jwt.sign(payload, privateKey, {
algorithm: 'ES256',
expiresIn: '7d' // 7일은 너무 김!
});
// ✅ 좋은 예: 짧은 만료 시간
const accessToken = jwt.sign(payload, privateKey, {
algorithm: 'ES256',
expiresIn: '15m' // 15분 권장
});
2025년 권장 만료 시간:
- 일반적인 경우: 15-60분
- 고보안 환경: 5-15분
- API 토큰: 최대 1시간
3. Refresh Token 전략
“15분마다 다시 로그인해야 하나요?” 당연히 아닙니다. 이를 해결하기 위해 Refresh Token을 사용합니다.
Access Token: 15분 (실제 API 요청에 사용)
Refresh Token: 7일 (새로운 Access Token 발급에만 사용)
왜 두 개를 사용하나?
- Access Token이 탈취되어도 15분 후 무효화
- Refresh Token은 안전하게 보관 (HttpOnly 쿠키 또는 DB)
- Refresh Token으로만 새 Access Token 발급 가능
Express.js에서 JWT 구현
이론을 알았으니 실제로 구현해봅시다. Express.js에서 로그인, 토큰 발급, 갱신, 검증까지 전체 흐름을 코드로 살펴봅니다.
로그인 및 토큰 발급
사용자가 로그인하면 Access Token과 Refresh Token을 발급합니다. Refresh Token은 HttpOnly 쿠키로 전송해서 JavaScript로 접근할 수 없게 합니다.
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// ✅ 환경 변수로 비밀키 관리
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
// Refresh Token 저장소 (실제로는 DB 사용)
const refreshTokens = new Map();
// 로그인 엔드포인트
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// 1. 사용자 확인
const user = await db.user.findUnique({ where: { email } });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 2. 비밀번호 검증
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 3. Access Token 생성 (15분)
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
ACCESS_TOKEN_SECRET,
{
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'your-app-name',
audience: 'your-app-users',
}
);
// 4. Refresh Token 생성 (7일)
const refreshTokenId = crypto.randomUUID();
const refreshToken = jwt.sign(
{
sub: user.id,
jti: refreshTokenId, // Token ID (취소 시 사용)
},
REFRESH_TOKEN_SECRET,
{
algorithm: 'HS256',
expiresIn: '7d',
}
);
// 5. Refresh Token을 DB에 저장
await db.refreshToken.create({
data: {
id: refreshTokenId,
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// 6. Refresh Token을 HttpOnly 쿠키로 전송
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // JavaScript로 접근 불가 (XSS 방어)
secure: process.env.NODE_ENV === 'production', // HTTPS에서만
sameSite: 'strict', // CSRF 방어
maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
});
// 7. Access Token은 JSON으로 반환
res.json({
accessToken,
user: {
id: user.id,
email: user.email,
role: user.role,
},
});
});
Token Refresh 엔드포인트
Access Token이 만료되면 Refresh Token으로 새 Access Token을 발급받습니다. 여기서 중요한 건 Token Rotation - Refresh Token도 함께 갱신해서 탈취 시 피해를 줄입니다.
// ✅ Refresh Token Rotation
app.post('/api/refresh', async (req, res) => {
// 1. 쿠키에서 Refresh Token 가져오기
const oldRefreshToken = req.cookies.refreshToken;
if (!oldRefreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
// 2. Refresh Token 검증
const decoded = jwt.verify(oldRefreshToken, REFRESH_TOKEN_SECRET);
// 3. DB에서 토큰 확인 (취소되었는지 확인)
const storedToken = await db.refreshToken.findUnique({
where: { id: decoded.jti },
});
if (!storedToken || storedToken.token !== oldRefreshToken) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// 4. 사용자 정보 조회
const user = await db.user.findUnique({
where: { id: decoded.sub },
});
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
// 5. 새로운 Access Token 생성
const newAccessToken = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
ACCESS_TOKEN_SECRET,
{
algorithm: 'HS256',
expiresIn: '15m',
}
);
// 6. ✅ Token Rotation: 새로운 Refresh Token 생성
const newRefreshTokenId = crypto.randomUUID();
const newRefreshToken = jwt.sign(
{
sub: user.id,
jti: newRefreshTokenId,
},
REFRESH_TOKEN_SECRET,
{
algorithm: 'HS256',
expiresIn: '7d',
}
);
// 7. DB에서 이전 토큰 삭제하고 새 토큰 저장
await db.$transaction([
db.refreshToken.delete({ where: { id: decoded.jti } }),
db.refreshToken.create({
data: {
id: newRefreshTokenId,
userId: user.id,
token: newRefreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
}),
]);
// 8. 새 Refresh Token을 쿠키로 전송
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
// 9. 새 Access Token 반환
res.json({ accessToken: newAccessToken });
} catch (error) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
인증 미들웨어
모든 보호된 API 요청에서 토큰을 검증해야 합니다. 미들웨어로 만들어두면 재사용하기 편합니다.
function authenticateToken(req, res, next) {
// 1. Authorization 헤더에서 토큰 추출
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
// 2. 토큰 검증
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
algorithms: ['HS256'],
issuer: 'your-app-name',
audience: 'your-app-users',
});
// 3. req.user에 사용자 정보 저장
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
}
// 사용 예시
app.get('/api/profile', authenticateToken, async (req, res) => {
const user = await db.user.findUnique({
where: { id: req.user.sub },
});
res.json({ user });
});
로그아웃
JWT는 stateless라서 서버에서 “무효화”할 수 없다고 생각할 수 있지만, Refresh Token을 DB에서 삭제하면 새 Access Token 발급이 불가능해집니다.
app.post('/api/logout', authenticateToken, async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
// DB에서 Refresh Token 삭제
await db.refreshToken.delete({
where: { id: decoded.jti },
});
} catch (error) {
// 토큰이 이미 만료되었거나 유효하지 않음
}
}
// 쿠키 삭제
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
Next.js 15에서의 JWT 인증 (2025년 업데이트)
중요한 변경사항: Next.js 15에서는 Middleware만으로 인증을 처리하는 것이 더 이상 권장되지 않습니다. CVE-2025-29927 보안 취약점으로 인해 다층 방어(Defense in Depth) 전략이 필요합니다.
문제점: Middleware만 사용하면 안 되는 이유
// ❌ 위험: Middleware만으로 인증 (2025년 이전 방식)
// middleware.ts
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
문제점:
- 정적 생성 경로 취약점: 빌드 시 정적 생성된 페이지는 Middleware를 거치지 않을 수 있음
- 제한된 컨텍스트: Middleware는 애플리케이션의 전체 컨텍스트에 접근 불가
- 복잡성: 정적 최적화와 인증 로직 충돌 가능
✅ Data Access Layer (DAL) 패턴
// lib/dal.ts (Data Access Layer)
import 'server-only'; // 서버에서만 실행되도록 보장
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { cache } from 'react';
// ✅ React cache로 중복 요청 방지
export const verifySession = cache(async () => {
const cookieStore = cookies();
const token = cookieStore.get('auth-token')?.value;
if (!token) {
return { isAuth: false };
}
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const { payload } = await jwtVerify(token, secret);
return {
isAuth: true,
userId: payload.sub as string,
role: payload.role as string,
};
} catch {
return { isAuth: false };
}
});
// ✅ 사용자 데이터 가져오기 (인증 필수)
export async function getUser() {
const session = await verifySession();
if (!session.isAuth) {
throw new Error('Unauthorized');
}
const user = await db.user.findUnique({
where: { id: session.userId },
});
return user;
}
Server Component에서 사용
// app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { verifySession } from '@/lib/dal';
export default async function DashboardPage() {
// ✅ 페이지 레벨에서 인증 확인
const session = await verifySession();
if (!session.isAuth) {
redirect('/login');
}
// ✅ DAL을 통해 데이터 가져오기
const user = await getUser();
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {user.email}</p>
</div>
);
}
Server Action에서 사용
// app/actions/update-profile.ts
'use server';
import { verifySession } from '@/lib/dal';
import { revalidatePath } from 'next/cache';
export async function updateProfile(formData: FormData) {
// ✅ Server Action에서도 반드시 인증 확인
const session = await verifySession();
if (!session.isAuth) {
throw new Error('Unauthorized');
}
const name = formData.get('name') as string;
await db.user.update({
where: { id: session.userId },
data: { name },
});
revalidatePath('/profile');
return { success: true };
}
API Route에서 사용
// app/api/profile/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifySession } from '@/lib/dal';
export async function GET(request: NextRequest) {
// ✅ API Route에서도 인증 확인
const session = await verifySession();
if (!session.isAuth) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: session.userId },
});
return NextResponse.json({ user });
}
Middleware는 보조적으로만 사용
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
const { pathname } = request.nextUrl;
// ✅ Middleware는 첫 번째 방어선으로만 사용
// 실제 인증은 각 페이지/API에서 수행
if (!token && pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
다층 방어 전략:
- Middleware: 초기 필터링 (UX 개선)
- Server Component: 페이지 렌더링 전 인증
- Server Action: 데이터 변경 전 인증
- API Route: API 호출 시 인증
- DAL: 데이터 접근 시 인증
React Router 7에서의 Protected Routes
중요 - 토큰 저장 위치: React 예제에서는 Access Token을 localStorage에 저장하지만, 이는 XSS 공격에 취약합니다. 더 안전한 방법은:
- Best: Access Token을 메모리(React state)에만 저장 + Refresh Token을 HttpOnly 쿠키에 저장
- Good: HttpOnly 쿠키에 토큰 저장 (단, CSRF 방어 필요)
- Acceptable (SPA 한계): localStorage 사용 + XSS 방어 강화 (CSP, 입력 검증 등)
localStorage는 JavaScript로 접근 가능하므로, XSS 공격 시 토큰이 탈취될 수 있습니다. 하지만 SPA 환경에서는 불가피한 경우가 있으므로, 다음 대책을 반드시 적용하세요:
- Content Security Policy (CSP) 설정
- 모든 사용자 입력 검증 및 이스케이프
- 신뢰할 수 없는 외부 스크립트 로드 금지
- Access Token 만료 시간 최소화 (5-15분)
React는 클라이언트 사이드이므로 Protected Route 패턴을 사용합니다.
Auth Context
// contexts/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface User {
id: string;
email: string;
role: string;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshToken: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// ✅ 페이지 로드 시 토큰 확인
useEffect(() => {
const checkAuth = async () => {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
setIsLoading(false);
return;
}
try {
// Access Token으로 사용자 정보 조회
const response = await fetch('/api/me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
} else if (response.status === 401) {
// Access Token 만료 → Refresh 시도
await refreshToken();
} else {
localStorage.removeItem('accessToken');
}
} catch (error) {
console.error('Auth check failed:', error);
localStorage.removeItem('accessToken');
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const login = async (email: string, password: string) => {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // 쿠키 포함
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Login failed');
}
const data = await response.json();
// Access Token은 localStorage에 저장
localStorage.setItem('accessToken', data.accessToken);
// Refresh Token은 HttpOnly 쿠키로 자동 저장됨
setUser(data.user);
};
const refreshToken = async () => {
try {
const response = await fetch('/api/refresh', {
method: 'POST',
credentials: 'include', // Refresh Token 쿠키 포함
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
// 사용자 정보 다시 조회
const userResponse = await fetch('/api/me', {
headers: {
Authorization: `Bearer ${data.accessToken}`,
},
});
if (userResponse.ok) {
const userData = await userResponse.json();
setUser(userData.user);
}
} else {
// Refresh Token도 만료됨
localStorage.removeItem('accessToken');
setUser(null);
}
} catch (error) {
localStorage.removeItem('accessToken');
setUser(null);
}
};
const logout = async () => {
await fetch('/api/logout', {
method: 'POST',
credentials: 'include',
});
localStorage.removeItem('accessToken');
setUser(null);
};
return (
<AuthContext.Provider
value=
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Protected Route Component
// components/ProtectedRoute.tsx
import { Navigate, useLocation, Outlet } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
interface ProtectedRouteProps {
requiredRole?: string;
}
export function ProtectedRoute({ requiredRole }: ProtectedRouteProps) {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div>Loading...</div>
</div>
);
}
if (!user) {
// 로그인 후 원래 페이지로 돌아가기 위해 state 전달
return <Navigate to="/login" state= replace />;
}
if (requiredRole && user.role !== requiredRole) {
return <Navigate to="/unauthorized" replace />;
}
// ✅ Outlet으로 하위 라우트 렌더링
return <Outlet />;
}
Router 설정
// App.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';
const router = createBrowserRouter([
{
path: '/',
element: <Home />,
},
{
path: '/login',
element: <Login />,
},
{
// ✅ Protected Route로 감싸기
element: <ProtectedRoute />,
children: [
{
path: '/dashboard',
element: <Dashboard />,
},
{
path: '/profile',
element: <Profile />,
},
],
},
{
// ✅ Admin 전용 라우트
element: <ProtectedRoute requiredRole="admin" />,
children: [
{
path: '/admin',
element: <AdminPanel />,
},
],
},
]);
function App() {
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);
}
API 요청 헬퍼
// utils/api.ts
import { jwtDecode } from 'jwt-decode';
// ✅ Access Token 만료 확인
function isTokenExpired(token: string): boolean {
try {
const decoded = jwtDecode(token);
const now = Date.now() / 1000;
// 만료 1분 전이면 갱신
return decoded.exp ? decoded.exp < now + 60 : true;
} catch {
return true;
}
}
// ✅ 자동 토큰 갱신
async function refreshAccessToken(): Promise<string | null> {
try {
const response = await fetch('/api/refresh', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
return data.accessToken;
}
return null;
} catch {
return null;
}
}
// ✅ API 요청 헬퍼
export async function apiRequest(url: string, options: RequestInit = {}) {
let accessToken = localStorage.getItem('accessToken');
// 토큰이 만료되었으면 갱신
if (accessToken && isTokenExpired(accessToken)) {
accessToken = await refreshAccessToken();
if (!accessToken) {
// Refresh도 실패 → 로그인 페이지로
window.location.href = '/login';
throw new Error('Authentication required');
}
}
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const response = await fetch(url, {
...options,
headers,
});
if (response.status === 401) {
// 401 에러 → 토큰 갱신 시도
const newToken = await refreshAccessToken();
if (newToken) {
// 갱신 성공 → 원래 요청 재시도
headers['Authorization'] = `Bearer ${newToken}`;
return fetch(url, { ...options, headers });
}
// 갱신 실패 → 로그인 페이지로
window.location.href = '/login';
throw new Error('Authentication required');
}
return response;
}
보안 주의사항
구현은 잘 했는데 보안 실수로 무너지는 경우가 많습니다. 아래 주의사항들은 실무에서 자주 발생하는 문제들입니다.
1. Payload에 민감 정보 넣지 않기
가장 흔한 실수입니다. JWT Payload는 암호화되지 않으므로 jwt.io에서 누구나 내용을 볼 수 있습니다.
// ❌ 나쁜 예
const token = jwt.sign({
userId: user.id,
email: user.email,
password: user.password, // 절대 안 됨!
creditCard: user.creditCard, // 절대 안 됨!
ssn: user.ssn, // 절대 안 됨!
}, secret);
// ✅ 좋은 예
const token = jwt.sign({
sub: user.id, // 사용자 ID만
email: user.email, // 이메일 정도는 OK
role: user.role, // 역할
}, secret);
2. HTTPS 필수
HTTP로 토큰을 전송하면 네트워크에서 누구나 가로챌 수 있습니다.
// ✅ HTTPS에서만 쿠키 전송
res.cookie('refreshToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // 프로덕션에서는 HTTPS만
sameSite: 'strict',
});
이유: HTTP로 전송하면 중간자 공격(Man-in-the-Middle)에 취약합니다.
3. 알고리즘 명시
알고리즘을 지정하지 않으면 none 알고리즘 공격에 취약합니다. 공격자가 서명 없는 토큰을 보내도 서버가 받아들일 수 있습니다.
// ❌ 나쁜 예: 알고리즘 지정 안 함
const decoded = jwt.verify(token, secret);
// ✅ 좋은 예: 허용 알고리즘 명시
const decoded = jwt.verify(token, secret, {
algorithms: ['ES256'], // 명시적으로 지정
});
4. Rate Limiting
로그인 엔드포인트는 무차별 대입 공격(Brute Force)의 대상이 됩니다. 요청 횟수를 제한해서 방어합니다.
const rateLimit = require('express-rate-limit');
// ✅ 로그인 엔드포인트에 제한
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 최대 5번
message: 'Too many login attempts, please try again later',
});
app.post('/api/login', loginLimiter, async (req, res) => {
// ...
});
정리
긴 내용을 다뤘습니다. 핵심만 빠르게 복습하고, 체크리스트로 구현을 점검해보세요.
핵심 요약
- JWT 구조: Header.Payload.Signature (Base64 인코딩, 서명으로 무결성 검증)
- Access Token: 15분 짧은 만료 시간
- Refresh Token: 7일, HttpOnly 쿠키 저장, Token Rotation 적용
- 권장 알고리즘: EdDSA > ES256 > RS256
- Next.js 15: Middleware만으로 부족, DAL 패턴 + 다층 방어
- React: Protected Route + Auth Context
- 보안: Payload에 민감 정보 X, HTTPS 필수, Rate Limiting
실전 체크리스트
- Access Token 만료 시간 15분 이하
- Refresh Token은 HttpOnly 쿠키에 저장
- Refresh Token Rotation 구현
- DB에 Refresh Token 저장 (취소 가능하도록)
- 비대칭 키 알고리즘 사용 (ES256 또는 EdDSA)
- Payload에 민감 정보 제외
- HTTPS에서만 쿠키 전송
- 알고리즘 명시적으로 지정
- Rate Limiting 적용
- (Next.js) DAL 패턴으로 다층 방어
다음 단계
참고 자료
공식 문서
- JWT.io - JWT 디버거 및 라이브러리
- RFC 7519 - JWT Specification
- Next.js Authentication Guide
- Auth0 Token Best Practices
보안 가이드
CVE-2025-29927 (Next.js Middleware Bypass)
- Vercel Postmortem on Next.js Middleware Bypass
- Datadog Security Labs - CVE-2025-29927 Analysis
- ProjectDiscovery - Technical Analysis
알고리즘 선택
- JWTs: Which Signing Algorithm Should I Use? - Scott Brady
- Nimbus JOSE + JWT Algorithm Selection Guide
- HMAC vs RSA vs ECDSA - WorkOS
댓글