Next.js App Router에서 CSRF 토큰 관리하기: Double Submit Cookie 패턴
CSRF 공격을 방어하기 위해 Double Submit Cookie 패턴을 구현했습니다. Edge Runtime 호환, 타이밍 공격 방지, 그리고 useCsrfToken 훅까지의 경험을 공유합니다.
1. CSRF 공격이란?
CSRF(Cross-Site Request Forgery)는 사용자가 로그인한 상태에서 악성 사이트가 사용자 대신 요청을 보내는 공격입니다.
공격 시나리오
1. 사용자가 은행 사이트에 로그인 (세션 쿠키 발급)
2. 사용자가 악성 사이트 방문
3. 악성 사이트에서 은행 API로 송금 요청 전송
4. 브라우저가 자동으로 세션 쿠키 포함
5. 은행 서버는 정상 요청으로 인식 → 송금 실행
왜 세션/JWT만으로는 부족한가?
브라우저는 동일 도메인에 대한 요청 시 자동으로 쿠키를 포함합니다. 공격자는 세션 토큰을 몰라도 요청을 위조할 수 있습니다.
2. Double Submit Cookie 패턴
2.1 동작 원리
┌─────────────────────────────────────────────────────────────┐
│ 서버 (미들웨어) │
│ │
│ 1. CSRF 토큰 생성 (32바이트 랜덤) │
│ 2. 쿠키에 저장 (HttpOnly: false) │
│ │
└────────────────────────┬────────────────────────────────────┘
│ Set-Cookie: csrf-token=abc123...
▼
┌─────────────────────────────────────────────────────────────┐
│ 클라이언트 │
│ │
│ 1. 쿠키에서 CSRF 토큰 읽기 │
│ 2. 요청 시 X-CSRF-Token 헤더에 포함 │
│ │
└────────────────────────┬────────────────────────────────────┘
│ X-CSRF-Token: abc123...
│ Cookie: csrf-token=abc123...
▼
┌─────────────────────────────────────────────────────────────┐
│ 서버 (API Route) │
│ │
│ 쿠키 토큰 === 헤더 토큰 → 검증 성공 │
│ (공격자는 쿠키 값을 읽을 수 없음 → 헤더에 토큰 포함 불가) │
│ │
└─────────────────────────────────────────────────────────────┘
2.2 왜 안전한가?
- Same-Origin Policy: 공격자 사이트에서는 다른 도메인의 쿠키를 읽을 수 없음
- 헤더 요구: 브라우저는 쿠키는 자동 전송하지만, 커스텀 헤더는 JavaScript로만 설정 가능
- 토큰 비교: 쿠키 값과 헤더 값이 일치해야 요청 허용
3. 서버 사이드 구현
3.1 CSRF 유틸리티
// src/lib/core/csrf.ts
import { NextRequest, NextResponse } from 'next/server';
export const CSRF_COOKIE_NAME = 'csrf-token';
export const CSRF_HEADER_NAME = 'X-CSRF-Token';
export const CSRF_TOKEN_LENGTH = 64; // 32바이트 hex
/**
* CSRF 토큰 생성 (Edge Runtime 호환)
*
* Web Crypto API 사용 (Node.js crypto 대신)
*/
export function generateCsrfToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes); // ← Edge Runtime 호환
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* 타이밍 안전한 문자열 비교
*
* 타이밍 공격 방지: 일정 시간에 비교 완료
*/
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i); // ← XOR 비교
}
return result === 0;
}
/**
* CSRF 토큰 검증
*/
export function validateCsrfToken(request: NextRequest): boolean {
const cookieToken = request.cookies.get(CSRF_COOKIE_NAME)?.value;
const headerToken = request.headers.get(CSRF_HEADER_NAME);
// 둘 중 하나라도 없으면 실패
if (!cookieToken || !headerToken) {
return false;
}
// 타이밍 안전한 비교
return timingSafeEqual(cookieToken, headerToken);
}
/**
* CSRF 검증이 필요한 HTTP 메서드
*/
export const CSRF_PROTECTED_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'];
export function isCsrfProtectedMethod(method: string): boolean {
return CSRF_PROTECTED_METHODS.includes(method.toUpperCase());
}
/**
* CSRF 검증 면제 경로
*/
export const CSRF_EXEMPT_PATHS = [
'/api/auth/', // NextAuth는 자체 CSRF 보호
];
export function isCsrfExemptPath(pathname: string): boolean {
return CSRF_EXEMPT_PATHS.some((path) => pathname.startsWith(path));
}
3.2 쿠키 설정
// src/lib/core/csrf.ts (계속)
/**
* CSRF 쿠키 옵션
*/
export function getCsrfCookieOptions(isProduction: boolean) {
return {
httpOnly: false, // ← 클라이언트 JavaScript에서 접근 필요
secure: isProduction,
sameSite: 'strict' as const, // ← Strict로 교차 사이트 요청 차단
path: '/',
maxAge: 60 * 60 * 24, // 24시간
};
}
/**
* 응답에 CSRF 쿠키 설정
*/
export function setCsrfCookie(
response: NextResponse,
token: string,
isProduction: boolean
): NextResponse {
response.cookies.set(
CSRF_COOKIE_NAME,
token,
getCsrfCookieOptions(isProduction)
);
return response;
}
3.3 미들웨어 통합
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import {
generateCsrfToken,
setCsrfCookie,
CSRF_COOKIE_NAME,
} from '@/lib/core/csrf';
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
const isProduction = process.env.NODE_ENV === 'production';
// CSRF 토큰이 없으면 새로 발급
if (!request.cookies.has(CSRF_COOKIE_NAME)) {
const token = generateCsrfToken();
setCsrfCookie(response, token, isProduction);
}
// Request ID 추가 (디버깅용)
const requestId = crypto.randomUUID().slice(0, 8);
response.headers.set('X-Request-ID', requestId);
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
3.4 API Handler에서 검증
// src/lib/core/api-handler.ts
import {
validateCsrfToken,
isCsrfProtectedMethod,
isCsrfExemptPath,
} from './csrf';
export function withErrorHandler<TParams = Record<string, string>>(
handler: ApiHandler<TParams>,
options: { csrf?: boolean } = {}
): ApiHandler<TParams> {
const { csrf = true } = options;
return async (request, context) => {
const path = new URL(request.url).pathname;
const method = request.method;
try {
// CSRF 검증 (POST, PUT, DELETE, PATCH)
if (csrf && isCsrfProtectedMethod(method) && !isCsrfExemptPath(path)) {
if (!validateCsrfToken(request)) {
throw new AuthorizationError('CSRF 토큰이 유효하지 않습니다.');
}
}
return await handler(request, context);
} catch (error) {
// 에러 처리...
}
};
}
4. 클라이언트 사이드 구현
4.1 useCsrfToken 훅
// src/hooks/useCsrfToken.ts
'use client';
import { useCallback, useEffect, useState } from 'react';
import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/lib/core/csrf';
/**
* 쿠키에서 CSRF 토큰 추출
*/
function getCsrfTokenFromCookie(): string | null {
if (typeof document === 'undefined') {
return null;
}
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === CSRF_COOKIE_NAME) {
return decodeURIComponent(value);
}
}
return null;
}
/**
* CSRF 토큰 훅
*
* @example
* function MyComponent() {
* const { csrfFetch } = useCsrfToken();
*
* const handleSubmit = async () => {
* await csrfFetch('/api/items', {
* method: 'POST',
* body: JSON.stringify(data),
* });
* };
* }
*/
export function useCsrfToken() {
const [csrfToken, setCsrfToken] = useState<string | null>(() => {
if (typeof document !== 'undefined') {
return getCsrfTokenFromCookie();
}
return null;
});
useEffect(() => {
// 쿠키 변경 감지 (1분마다)
const interval = setInterval(() => {
const newToken = getCsrfTokenFromCookie();
setCsrfToken((prev) => (newToken !== prev ? newToken : prev));
}, 60000);
return () => clearInterval(interval);
}, []);
/**
* CSRF 헤더가 포함된 fetch 래퍼
*/
const csrfFetch = useCallback(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const token = getCsrfTokenFromCookie(); // 최신 토큰 사용
const headers = new Headers(init?.headers);
if (token) {
headers.set(CSRF_HEADER_NAME, token);
}
// Content-Type 기본값
if (!headers.has('Content-Type') && init?.body) {
headers.set('Content-Type', 'application/json');
}
return fetch(input, {
...init,
headers,
credentials: 'same-origin', // ← 쿠키 전송 보장
});
},
[]
);
/**
* CSRF 헤더 객체 반환
*/
const getCsrfHeaders = useCallback((): Record<string, string> => {
const token = getCsrfTokenFromCookie();
return token ? { [CSRF_HEADER_NAME]: token } : {};
}, []);
return {
csrfToken,
csrfFetch,
getCsrfHeaders,
CSRF_HEADER_NAME,
};
}
4.2 사용 예시
// 방법 1: csrfFetch 사용 (권장)
function ItemForm() {
const { csrfFetch } = useCsrfToken();
const handleSubmit = async (data: FormData) => {
const response = await csrfFetch('/api/items', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(data)),
});
if (!response.ok) {
throw new Error('저장 실패');
}
};
return <form onSubmit={...}>...</form>;
}
// 방법 2: 기존 fetch에 헤더 추가
function ItemForm2() {
const { getCsrfHeaders } = useCsrfToken();
const handleSubmit = async (data: FormData) => {
const response = await fetch('/api/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getCsrfHeaders(), // ← 헤더 추가
},
body: JSON.stringify(Object.fromEntries(data)),
});
};
}
// 방법 3: axios 인터셉터에 추가
import axios from 'axios';
const api = axios.create();
api.interceptors.request.use((config) => {
const token = getCsrfTokenFromCookie();
if (token) {
config.headers[CSRF_HEADER_NAME] = token;
}
return config;
});
5. 타이밍 공격 방지
5.1 문제
일반적인 문자열 비교는 첫 불일치 문자에서 즉시 반환합니다.
// ❌ 취약한 비교
function unsafeCompare(a: string, b: string): boolean {
return a === b; // 첫 불일치 시 즉시 false
}
공격자는 응답 시간 차이를 측정하여 토큰을 한 글자씩 추측할 수 있습니다.
5.2 해결: 상수 시간 비교
// ✅ 안전한 비교
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) {
return false; // 길이 비교는 괜찮음
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0; // 모든 문자 비교 후 결과 반환
}
동작 원리:
- XOR: 같으면 0, 다르면 1 이상
- OR 누적: 하나라도 다르면 result > 0
- 전체 순회 후 결과 반환: 항상 같은 시간 소요
6. 핵심 개념 정리
| 개념 | 설명 | 값 |
|---|---|---|
| Double Submit Cookie | 쿠키 + 헤더로 이중 검증 | 동일 토큰 |
| HttpOnly: false | 클라이언트에서 쿠키 읽기 허용 | 의도적 |
| SameSite: Strict | 교차 사이트 요청 시 쿠키 미전송 | 추가 보호 |
| 타이밍 안전 비교 | XOR + OR로 상수 시간 비교 | 공격 방지 |
| 면제 경로 | NextAuth는 자체 CSRF 보호 | /api/auth/ |
7. Server Actions와 CSRF
Next.js 14+ Server Actions는 자체 CSRF 보호가 내장되어 있습니다.
// Server Action - 별도 CSRF 처리 불필요
'use server';
export async function createItem(formData: FormData) {
// Next.js가 자동으로 CSRF 검증
const name = formData.get('name');
// ...
}
따라서 CSRF 검증은 API Routes에만 적용하면 됩니다.
8. 베스트 프랙티스
체크리스트
- [ ] 미들웨어에서 CSRF 토큰 자동 발급
- [ ] API Route에서 POST/PUT/DELETE/PATCH 검증
- [ ] 타이밍 안전한 비교 사용
- [ ] SameSite=Strict 설정
- [ ] NextAuth 경로 면제 (
/api/auth/) - [ ] useCsrfToken 훅으로 클라이언트 통합
테스트
// 테스트: CSRF 토큰 없이 요청 시 403
test('POST without CSRF token returns 403', async () => {
const response = await fetch('/api/items', {
method: 'POST',
body: JSON.stringify({ name: 'test' }),
});
expect(response.status).toBe(403);
});
// 테스트: CSRF 토큰 있으면 성공
test('POST with CSRF token succeeds', async () => {
const { csrfFetch } = useCsrfToken();
const response = await csrfFetch('/api/items', {
method: 'POST',
body: JSON.stringify({ name: 'test' }),
});
expect(response.status).toBe(201);
});
9. FAQ
Q: HttpOnly: false가 위험하지 않나요?
A: CSRF 토큰은 XSS로 탈취되어도 세션 쿠키(HttpOnly: true)보다 피해가 적습니다. CSRF 토큰은 요청 위조 방지용이지 인증용이 아닙니다.
Q: SameSite=Strict만으로 충분하지 않나요?
A: 최신 브라우저에서는 대부분 보호되지만, 레거시 브라우저 지원과 깊은 방어(Defense in Depth)를 위해 Double Submit Cookie를 권장합니다.
Q: 토큰 만료 시간은 어떻게 설정하나요?
A: 세션과 동일하게 또는 더 짧게 설정합니다 (24시간). 페이지 새로고침 시 미들웨어에서 갱신됩니다.
Q: 모바일 앱에서도 CSRF 보호가 필요한가요?
A: 모바일 앱은 브라우저가 아니므로 CSRF 공격 대상이 아닙니다. API 토큰 인증만으로 충분합니다.
Q: Edge Runtime에서 왜 Web Crypto API를 사용하나요?
A: Edge Runtime (Cloudflare Workers, Vercel Edge)은 Node.js crypto 모듈을 지원하지 않습니다. Web Crypto API는 표준이며 모든 런타임에서 지원됩니다.
10. 참고 자료
- OWASP CSRF Prevention Cheat Sheet
- Double Submit Cookie Pattern
- Next.js Middleware 문서
- Web Crypto API (MDN)
11. 다음 단계
CSRF 보호를 구현했다면, 세션 비활성 타임아웃으로 추가 보안을 강화할 수 있습니다.
시리즈 목차:
- Prisma Extension으로 민감 데이터 암호화 자동화하기
- Next.js에서 2FA/TOTP 인증 구현하기
- API 에러 처리 표준화: withErrorHandler 패턴
- 민감 데이터 접근 추적: Audit Logging 구현
- Next.js App Router에서 CSRF 토큰 관리하기 ← 현재 글
- useSessionActivity: 비활성 사용자 자동 로그아웃