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년 권장 알고리즘 (현재 표준):

  1. EdDSA (Ed25519): 최신, 빠른 성능, 작은 키 크기
  2. ES256 (ECDSA P-256): 작은 키 크기, 우수한 보안
  3. 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();
}

문제점:

  1. 정적 생성 경로 취약점: 빌드 시 정적 생성된 페이지는 Middleware를 거치지 않을 수 있음
  2. 제한된 컨텍스트: Middleware는 애플리케이션의 전체 컨텍스트에 접근 불가
  3. 복잡성: 정적 최적화와 인증 로직 충돌 가능

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).*)'],
};

다층 방어 전략:

  1. Middleware: 초기 필터링 (UX 개선)
  2. Server Component: 페이지 렌더링 전 인증
  3. Server Action: 데이터 변경 전 인증
  4. API Route: API 호출 시 인증
  5. DAL: 데이터 접근 시 인증

React Router 7에서의 Protected Routes

중요 - 토큰 저장 위치: React 예제에서는 Access Token을 localStorage에 저장하지만, 이는 XSS 공격에 취약합니다. 더 안전한 방법은:

  1. Best: Access Token을 메모리(React state)에만 저장 + Refresh Token을 HttpOnly 쿠키에 저장
  2. Good: HttpOnly 쿠키에 토큰 저장 (단, CSRF 방어 필요)
  3. 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) => {
  // ...
});

정리

긴 내용을 다뤘습니다. 핵심만 빠르게 복습하고, 체크리스트로 구현을 점검해보세요.

핵심 요약

  1. JWT 구조: Header.Payload.Signature (Base64 인코딩, 서명으로 무결성 검증)
  2. Access Token: 15분 짧은 만료 시간
  3. Refresh Token: 7일, HttpOnly 쿠키 저장, Token Rotation 적용
  4. 권장 알고리즘: EdDSA > ES256 > RS256
  5. Next.js 15: Middleware만으로 부족, DAL 패턴 + 다층 방어
  6. React: Protected Route + Auth Context
  7. 보안: 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 패턴으로 다층 방어

다음 단계

참고 자료

공식 문서

보안 가이드

CVE-2025-29927 (Next.js Middleware Bypass)

알고리즘 선택

양자 내성 암호

댓글