JWT 인증 시리즈 4편: JWT 보안 강화 및 모범 사례

2025. 4. 27. 22:20·Study/Authentication

안녕하세요! JWT 인증 시리즈의 네 번째 글입니다. 1편에서는 JWT의 기본 개념과, 2편에서는 Node.js/Express를 활용한 서버 구현을, 3편에서는 프론트엔드에서의 JWT 구현에 대해 알아보았습니다. 이번 4편에서는 JWT 보안을 강화하고 토큰 탈취를 방지하는 다양한 방법과 모범 사례에 대해 자세히 알아보겠습니다.

목차

  1. JWT 보안의 중요성
  2. JWT 취약점 이해하기
  3. 토큰 저장 전략과 보안
  4. 토큰 무효화 전략
  5. 토큰 수명 최적화
  6. JWT 탈취 방지 기술
  7. 리프레시 토큰 보안
  8. CSRF & XSS 공격 방어
  9. 블랙리스트와 화이트리스트 전략
  10. JWT 서명 알고리즘 강화
  11. 고급 JWT 보안 기술
  12. 마무리

JWT 보안의 중요성

JWT(JSON Web Token)는 간단하고 강력한 인증 메커니즘이지만, 부적절하게 구현될 경우 심각한 보안 위험을 초래할 수 있습니다. JWT가 탈취되면 공격자는 토큰이 만료되기 전까지 토큰 소유자로 위장할 수 있으며, 이로 인해 개인 정보 유출, 권한 없는 리소스 접근, 데이터 손상 등의 문제가 발생할 수 있습니다.

JWT 보안의 중요성을 보여주는 주요 통계:

  • 2022년 보안 연구에 따르면, 웹 애플리케이션 공격의 약 25%가 인증 메커니즘을 대상으로 합니다.
  • 토큰 기반 인증 시스템의 취약점을 이용한 데이터 침해 사례는 매년 23% 증가하고 있습니다.
  • 부적절한 JWT 구현으로 인한 보안 사고의 76%는 토큰 저장 및 전송 방식의 취약점과 관련이 있습니다.

이러한 위험을 최소화하기 위해 JWT 구현 시 보안 모범 사례를 따르는 것이 중요합니다.


JWT 취약점 이해하기

JWT와 관련된 주요 보안 취약점을 이해하는 것은 효과적인 방어 전략을 수립하는 첫 단계입니다.

 

1. 알고리즘 변경 공격(Algorithm Switching)

JWT는 헤더에 서명 알고리즘 정보를 포함하고 있습니다. 공격자는 이 정보를 수정하여 서명 검증 방식을 변경할 수 있습니다.

취약한 예시:

// 알고리즘을 검증하지 않는 취약한 코드
const verifyToken = (token) => {
  try {
    const decoded = jwt.verify(token, secretKey);
    return decoded;
  } catch (error) {
    return null;
  }
};

보안 개선 예시:

// 알고리즘을 명시적으로 지정하여 공격 방지
const verifyToken = (token: string): UserPayload | null => {
  try {
    const decoded = jwt.verify(token, secretKey, { 
      algorithms: ['HS256'] // 허용할 알고리즘 명시적 지정
    }) as UserPayload;
    return decoded;
  } catch (error) {
    return null;
  }
};

 

2. 토큰 탈취

웹 스토리지, 안전하지 않은 쿠키, 또는 네트워크 전송 중 토큰이 탈취될 수 있습니다.

취약점 예시:

  • localStorage/sessionStorage에 토큰 저장(XSS 공격에 취약)
  • 안전하지 않은 쿠키 설정(HttpOnly, Secure 플래그 없음)
  • HTTPS 없이 토큰 전송

 

3. 토큰 재사용 공격

탈취된 토큰이 원래 사용자와 공격자에 의해 동시에 사용될 수 있습니다.

 

4. None 알고리즘 공격

일부 JWT 라이브러리는 'none' 알고리즘을 허용하며, 이를 통해 공격자는 서명 검증 없이 토큰을 조작할 수 있습니다.


토큰 저장 전략과 보안

토큰 저장 위치는 JWT 보안에 중요한 영향을 미칩니다. 각 저장 방식의 보안 특성을 비교해 보겠습니다.

 

1. 웹 스토리지(localStorage/sessionStorage)

보안 위험:

  • XSS(Cross-Site Scripting) 공격에 매우 취약
  • 공격자가 JavaScript를 통해 쉽게 액세스 가능

보안 개선 방법:

// 🚨 보안 위험: 권장하지 않음
localStorage.setItem('accessToken', token);

// 대안: XSS 방어와 함께 사용 (여전히 위험)
import DOMPurify from 'dompurify';

// XSS 방어를 위한 입력 정화
const sanitizeInput = (input: string) => {
  return DOMPurify.sanitize(input);
};

// 사용자 입력을 받는 모든 곳에서 정화 처리
const userInput = sanitizeInput(document.getElementById('input').value);
document.getElementById('output').innerHTML = userInput;

 

2. 쿠키 기반 저장

보안 특성:

  • HttpOnly 플래그로 JavaScript 접근 차단(XSS 방어)
  • Secure 플래그로 HTTPS 연결에서만 전송
  • SameSite 속성으로 CSRF 공격 방어
  • 자동으로 모든 요청에 포함됨

보안 설정 예시:

// 서버 측 쿠키 설정 (Express.js)
res.cookie('refreshToken', token, {
  httpOnly: true, // JavaScript에서 접근 불가
  secure: process.env.NODE_ENV === 'production', // HTTPS에서만 전송
  sameSite: 'strict', // 동일 사이트 요청에만 쿠키 전송
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
  path: '/' // 모든 경로에서 액세스 가능
});

// Next.js API 라우트에서의 쿠키 설정
import { serialize } from 'cookie';

export default function handler(req, res) {
  // 쿠키 설정
  const cookieOptions = {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 60 * 60 * 24 * 7, // 7일
    path: '/'
  };
  
  res.setHeader('Set-Cookie', serialize('refreshToken', token, cookieOptions));
  res.status(200).json({ success: true });
}

 

3. 메모리 저장 전략

보안 특성:

  • 페이지 새로고침 시 데이터 손실
  • 브라우저 탭 간 공유 불가능
  • JavaScript 변수로만 저장되어 영구 저장소에 접근하지 않음

구현 예시:

// 메모리 기반 토큰 저장소
let memoryToken: string | null = null;

export const tokenService = {
  setToken: (token: string) => {
    memoryToken = token;
  },
  getToken: () => memoryToken,
  removeToken: () => {
    memoryToken = null;
  }
};

// API 요청에 토큰 포함
import axios from 'axios';
import { tokenService } from './tokenService';

const api = axios.create({
  baseURL: '/api'
});

api.interceptors.request.use(config => {
  const token = tokenService.getToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

 

4. 하이브리드 접근법(권장)

액세스 토큰과 리프레시 토큰을 다른 방식으로 저장하는 하이브리드 접근법이 현재 가장 권장되는 방식입니다.

구현 예시:

// 액세스 토큰: 메모리에 저장 (짧은 만료 시간)
let accessToken: string | null = null;

// 리프레시 토큰: HttpOnly 쿠키로 저장 (서버에서 설정)
export const authService = {
  // 로그인 함수
  login: async (credentials: { email: string; password: string }) => {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(credentials),
      credentials: 'include' // 쿠키 포함
    });
    
    if (!response.ok) {
      throw new Error('Login failed');
    }
    
    const data = await response.json();
    
    // 액세스 토큰은 메모리에 저장
    accessToken = data.accessToken;
    
    return data.user;
  },
  
  // 토큰 가져오기
  getAccessToken: () => accessToken,
  
  // 로그아웃
  logout: async () => {
    // 액세스 토큰 메모리에서 제거
    accessToken = null;
    
    // 리프레시 토큰 쿠키 제거 요청
    await fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include'
    });
  },
  
  // 토큰 새로고침
  refreshToken: async () => {
    try {
      // 리프레시 토큰은 쿠키에 저장되어 있으므로 별도로 보내지 않음
      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
        credentials: 'include'
      });
      
      if (!response.ok) {
        throw new Error('Token refresh failed');
      }
      
      const data = await response.json();
      
      // 새 액세스 토큰 메모리에 저장
      accessToken = data.accessToken;
      
      return true;
    } catch (error) {
      accessToken = null;
      return false;
    }
  }
};

토큰 무효화 전략

JWT는 기본적으로 발급 후 서버에서 직접 무효화할 수 없습니다. 하지만 다음과 같은 전략을 통해 토큰 무효화를 구현할 수 있습니다.

1. 블랙리스트 방식

만료되지 않은 토큰을 무효화하기 위해 Redis와 같은 인메모리 데이터베이스를 사용한 블랙리스트 구현:

// 서버 측 구현 (Node.js)
import { createClient } from 'redis';

// Redis 클라이언트 생성
const redisClient = createClient({
  url: process.env.REDIS_URL
});

redisClient.connect().catch(console.error);

// 토큰 블랙리스트에 추가 (로그아웃 시)
export const blacklistToken = async (token: string, exp: number) => {
  const now = Math.floor(Date.now() / 1000);
  const ttl = exp - now; // 남은 만료 시간(초)
  
  if (ttl <= 0) return; // 이미 만료된 토큰은 블랙리스트에 추가하지 않음
  
  // 토큰 해시를 블랙리스트에 추가 (전체 토큰 저장 대신 보안 강화)
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  await redisClient.set(`bl:${tokenHash}`, '1', { EX: ttl });
};

// 토큰이 블랙리스트에 있는지 확인
export const isTokenBlacklisted = async (token: string) => {
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  const result = await redisClient.get(`bl:${tokenHash}`);
  return result !== null;
};

// 토큰 검증 미들웨어에 블랙리스트 확인 추가
export const verifyToken = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
  }
  
  try {
    // 블랙리스트 확인
    const blacklisted = await isTokenBlacklisted(token);
    if (blacklisted) {
      return res.status(401).json({ message: '만료된 토큰입니다.' });
    }
    
    // 토큰 검증
    const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
  }
};

 

2. 토큰 버전 관리

사용자별 토큰 버전을 관리하여 특정 버전 이전의 모든 토큰을 무효화하는 방식:

// 사용자 모델에 tokenVersion 필드 추가
interface User {
  id: number;
  email: string;
  tokenVersion: number;
  // 기타 필드
}

// 토큰 생성 시 토큰 버전 포함
const generateToken = (user: User) => {
  return jwt.sign(
    {
      id: user.id,
      email: user.email,
      tokenVersion: user.tokenVersion
    },
    JWT_SECRET,
    { expiresIn: '15m' }
  );
};

// 로그아웃 시 토큰 버전 증가
const logout = async (userId: number) => {
  // 데이터베이스에서 사용자 찾기
  const user = await findUserById(userId);
  
  if (user) {
    // 토큰 버전 증가
    user.tokenVersion += 1;
    // 사용자 정보 업데이트
    await updateUser(user);
  }
};

// 토큰 검증 시 버전 확인
const verifyTokenVersion = async (token: string) => {
  try {
    const decoded = jwt.verify(token, JWT_SECRET) as { id: number; tokenVersion: number };
    
    // 데이터베이스에서 현재 사용자 버전 조회
    const user = await findUserById(decoded.id);
    
    // 토큰의 버전이 사용자의 현재 버전과 일치하는지 확인
    if (!user || decoded.tokenVersion !== user.tokenVersion) {
      throw new Error('Token version mismatch');
    }
    
    return decoded;
  } catch (error) {
    return null;
  }
};

 

3. 짧은 만료 시간 설정

액세스 토큰의 만료 시간을 짧게 설정하고 리프레시 토큰을 사용하는 방식:

// 액세스 토큰 생성 (짧은 만료 시간)
const generateAccessToken = (user: User) => {
  return jwt.sign(
    { id: user.id, role: user.role },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' } // 15분 만료
  );
};

// 리프레시 토큰 생성 (긴 만료 시간)
const generateRefreshToken = (user: User) => {
  return jwt.sign(
    { id: user.id, tokenVersion: user.tokenVersion },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' } // 7일 만료
  );
};

반응형

토큰 수명 최적화

토큰 만료 시간은 보안과 사용자 경험 사이의 균형을 고려하여 설정해야 합니다.

1. 권장 토큰 수명 설정:

  • 액세스 토큰: 5분 ~ 15분 (보안이 중요한 경우 더 짧게)
  • 리프레시 토큰: 1일 ~ 30일 (필요에 따라 조정)

 

2. 컨텍스트별 토큰 수명 조정:

// 컨텍스트에 따른 토큰 수명 조정
const generateContextAwareToken = (user: User, context: { 
  deviceType: 'mobile' | 'desktop' | 'public', 
  riskScore: number 
}) => {
  // 기본 만료 시간 (15분)
  let expiresIn = '15m';
  
  // 디바이스 유형에 따른 조정
  if (context.deviceType === 'mobile') {
    expiresIn = '30m'; // 모바일은 더 안전하다고 가정
  } else if (context.deviceType === 'public') {
    expiresIn = '5m'; // 공용 디바이스는 보안 강화
  }
  
  // 위험 점수에 따른 추가 조정 (0-100 범위)
  if (context.riskScore > 70) {
    expiresIn = '5m'; // 위험 높음
  } else if (context.riskScore < 20) {
    expiresIn = '1h'; // 위험 낮음
  }
  
  return jwt.sign(
    { id: user.id, role: user.role },
    JWT_SECRET,
    { expiresIn }
  );
};

3. 사용자 활동 기반 토큰 갱신:

// 클라이언트 측에서 토큰 갱신 관리
const TOKEN_REFRESH_THRESHOLD = 5 * 60; // 만료 5분 전에 갱신

// 토큰의 만료 시간 파악
const getTokenExpiration = (token: string): number => {
  try {
    const decoded = jwt_decode<{ exp: number }>(token);
    return decoded.exp;
  } catch (error) {
    return 0;
  }
};

// 사용자 활동 감지와 토큰 갱신
const setupTokenRefresh = () => {
  // 토큰 상태 확인 함수
  const checkTokenStatus = async () => {
    const token = tokenService.getAccessToken();
    if (!token) return;
    
    const expTime = getTokenExpiration(token);
    const currentTime = Math.floor(Date.now() / 1000);
    const timeLeft = expTime - currentTime;
    
    // 만료 임계값보다 적게 남았다면 토큰 갱신
    if (timeLeft > 0 && timeLeft < TOKEN_REFRESH_THRESHOLD) {
      await tokenService.refreshToken();
    }
  };
  
  // 사용자 활동 이벤트에 토큰 상태 확인 연결
  const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
  events.forEach(event => {
    document.addEventListener(event, checkTokenStatus, { passive: true });
  });
  
  // 주기적으로도 확인
  setInterval(checkTokenStatus, 60 * 1000); // 1분마다
};

JWT 탈취 방지 기술

토큰이 탈취되더라도 악용을 최소화하기 위한 여러 기술을 살펴보겠습니다.

1. 토큰 바인딩 (Token Binding)

토큰을 특정 요소(디바이스 지문, IP 등)에 바인딩하여 다른 환경에서 사용을 방지합니다.

// 클라이언트 지문 생성 (디바이스 정보 수집)
import FingerprintJS from '@fingerprintjs/fingerprintjs';

// 디바이스 지문 가져오기
const getDeviceFingerprint = async () => {
  const fp = await FingerprintJS.load();
  const result = await fp.get();
  return result.visitorId;
};

// 서버에서 토큰 발급 시 지문 추가
const generateBoundToken = async (user: User, fingerprint: string) => {
  return jwt.sign(
    {
      id: user.id,
      role: user.role,
      fingerprint: crypto.createHash('sha256').update(fingerprint).digest('hex')
    },
    JWT_SECRET,
    { expiresIn: '15m' }
  );
};

// 클라이언트 측 로그인 로직
const login = async (email: string, password: string) => {
  // 디바이스 지문 가져오기
  const fingerprint = await getDeviceFingerprint();
  
  // 로그인 요청에 지문 포함
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password, fingerprint })
  });
  
  const data = await response.json();
  return data;
};

// 서버 측 토큰 검증 로직
const verifyBoundToken = async (token: string, req: Request) => {
  try {
    // 토큰 디코딩
    const decoded = jwt.verify(token, JWT_SECRET) as {
      id: number;
      fingerprint: string;
    };
    
    // 요청에서 지문 가져오기
    const fingerprint = req.headers['x-device-fingerprint'] as string;
    if (!fingerprint) {
      throw new Error('Fingerprint missing');
    }
    
    // 지문 해시 생성
    const fingerprintHash = crypto
      .createHash('sha256')
      .update(fingerprint)
      .digest('hex');
    
    // 토큰의 지문과 비교
    if (decoded.fingerprint !== fingerprintHash) {
      throw new Error('Fingerprint mismatch');
    }
    
    return decoded;
  } catch (error) {
    return null;
  }
};

 

2. 추가 클레임 포함

위치 정보, 디바이스 ID 등 컨텍스트 정보를 토큰에 포함하여 보안을 강화합니다.

// 클라이언트에서 컨텍스트 정보 수집
const getContextInfo = async () => {
  // 위치 정보 가져오기 (허용된 경우)
  let locationInfo = null;
  try {
    const position = await new Promise<GeolocationPosition>((resolve, reject) => {
      navigator.geolocation.getCurrentPosition(resolve, reject);
    });
    
    locationInfo = {
      lat: position.coords.latitude,
      lng: position.coords.longitude,
      accuracy: position.coords.accuracy
    };
  } catch (error) {
    console.log('Location access denied or unavailable');
  }
  
  // 디바이스 및 네트워크 정보
  const deviceInfo = {
    userAgent: navigator.userAgent,
    language: navigator.language,
    platform: navigator.platform,
    screenSize: `${window.screen.width}x${window.screen.height}`,
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    connection: navigator.connection ? {
      effectiveType: (navigator.connection as any).effectiveType,
      downlink: (navigator.connection as any).downlink
    } : null
  };
  
  return {
    locationInfo,
    deviceInfo
  };
};

// 서버에서 컨텍스트 정보를 토큰에 포함
const generateContextAwareToken = (user: User, context: any) => {
  // 컨텍스트 정보 요약 해시 생성
  const contextHash = crypto
    .createHash('sha256')
    .update(JSON.stringify(context))
    .digest('hex');
  
  return jwt.sign(
    {
      id: user.id,
      role: user.role,
      context: {
        hash: contextHash,
        ip: context.ip, // 요청 IP
        userAgent: context.userAgent.substring(0, 50), // 요약 정보만 포함
        geo: context.locationInfo ? {
          area: getGeoArea(context.locationInfo.lat, context.locationInfo.lng)
        } : null
      }
    },
    JWT_SECRET,
    { expiresIn: '15m' }
  );
};

// 토큰 검증 시 컨텍스트 정보 확인
const verifyContextAwareToken = (token: string, req: Request) => {
  try {
    const decoded = jwt.verify(token, JWT_SECRET) as {
      id: number;
      context: {
        ip: string;
        userAgent: string;
        geo?: { area: string };
      };
    };
    
    // IP 검증 (완전히 일치하지 않을 수 있으므로 유연하게 처리)
    const ipChanged = !isSimilarIP(decoded.context.ip, req.ip);
    
    // 사용자 에이전트 검증 (브라우저 업데이트 등으로 약간 변경될 수 있음)
    const userAgentChanged = !req.headers['user-agent']?.includes(decoded.context.userAgent);
    
    // 위치 정보 검증 (가능한 경우)
    let geoChanged = false;
    if (decoded.context.geo && req.headers['x-geo-location']) {
      const currentGeo = JSON.parse(req.headers['x-geo-location'] as string);
      geoChanged = decoded.context.geo.area !== getGeoArea(currentGeo.lat, currentGeo.lng);
    }
    
    // 변화 점수 계산
    let suspicionScore = 0;
    if (ipChanged) suspicionScore += 0.5;
    if (userAgentChanged) suspicionScore += 0.3;
    if (geoChanged) suspicionScore += 0.7;
    
    // 점수가 임계값을 초과하면 추가 검증 필요
    if (suspicionScore >= 0.8) {
      throw new Error('Context verification failed');
    }
    
    return decoded;
  } catch (error) {
    return null;
  }
};

 

3. 동적 토큰 사용 (One-Time Tokens)

각 요청마다 새로운 토큰을 사용하는 전략으로, 토큰이 탈취되더라도 재사용이 불가능합니다.

// 서버 측: 토큰 체인 생성
const generateTokenChain = (user: User, chainLength = 5) => {
  // 마지막 토큰부터 시작하여 체인 생성
  let currentToken = crypto.randomBytes(32).toString('hex');
  const tokens = [currentToken];
  
  // 이전 토큰의 해시가 다음 토큰이 되는 체인 생성
  for (let i = 1; i < chainLength; i++) {
    currentToken = crypto
      .createHash('sha256')
      .update(currentToken)
      .digest('hex');
      
    tokens.unshift(currentToken);
  }
  
  // 사용자 정보와 함께 첫 번째 토큰 반환
  const rootToken = tokens[0];
  
  // 나머지 토큰은 서버에 저장
  saveTokenChain(user.id, tokens.slice(1));
  
  return jwt.sign(
    { id: user.id, role: user.role, chainIndex: 0 },
    rootToken, // 토큰 자체를 시크릿 키로 사용
    { expiresIn: '1h' }
  );
};

// 클라이언트 측: 토큰 사용 및 갱신
const useNextToken = async (currentToken) => {
  // 현재 토큰을 사용하여 API 호출
  const response = await fetch('/api/secure-data', {
    headers: { 'Authorization': `Bearer ${currentToken}` }
  });
  
  // 응답에서 다음 토큰 가져오기
  const nextToken = response.headers.get('X-Next-Token');
  
  if (nextToken) {
    // 새 토큰 저장
    tokenService.setToken(nextToken);
  }
  
  return response;
};

// 서버 측: 요청 처리 및 새 토큰 발급
const handleSecureRequest = async (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
  }
  
  try {
    // 토큰 검증
    const decoded = jwt.verify(token, JWT_SECRET) as {
      id: number;
      chainIndex: number;
    };
    
    // 사용자 검증
    const user = await findUserById(decoded.id);
    if (!user) {
      throw new Error('User not found');
    }
    
    // 다음 토큰 가져오기
    const nextToken = await getNextTokenInChain(user.id, decoded.chainIndex + 1);
    
    // 응답 헤더에 다음 토큰 포함
    res.setHeader('X-Next-Token', nextToken);
    
    // 요청 처리
    // ...
    
    return res.status(200).json({ data: '보안 데이터' });
  } catch (error) {
    return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
  }
};

 

4. 이벤트 기반 토큰 모니터링 (Event-Based Token Monitoring)

토큰 사용 패턴을 모니터링하여 이상 징후를 감지하고 대응합니다.

// 토큰 사용 로깅
const logTokenUsage = async (userId: number, tokenId: string, context: any) => {
  await db.tokenUsageLogs.create({
    userId,
    tokenId,
    timestamp: new Date(),
    ip: context.ip,
    userAgent: context.userAgent,
    endpoint: context.endpoint,
    geoLocation: context.geoLocation
  });
};

// 비정상적인 토큰 사용 감지
const detectAbnormalTokenUsage = async (userId: number) => {
  // 최근 로그 가져오기
  const recentLogs = await db.tokenUsageLogs.findMany({
    where: { userId },
    orderBy: { timestamp: 'desc' },
    take: 20
  });
  
  if (recentLogs.length < 5) return { suspicious: false };
  
  // 이상 징후 분석
  const uniqueIPs = new Set(recentLogs.map(log => log.ip)).size;
  const timeSpread = (recentLogs[0].timestamp.getTime() - recentLogs[recentLogs.length - 1].timestamp.getTime()) / (1000 * 60); // 분 단위
  const requestFrequency = recentLogs.length / timeSpread; // 분당 요청 수
  
  // 이상 징후 점수 계산
  let suspicionScore = 0;
  
  // 다양한 IP에서 접근
  if (uniqueIPs > 3) suspicionScore += uniqueIPs * 0.2;
  
  // 비정상적인 요청 빈도
  if (requestFrequency > 20) suspicionScore += requestFrequency * 0.05;
  
  // 지리적 이동 속도 확인
  for (let i = 1; i < recentLogs.length; i++) {
    const prev = recentLogs[i-1];
    const curr = recentLogs[i];
    
    if (prev.geoLocation && curr.geoLocation) {
      const distance = calculateGeoDistance(prev.geoLocation, curr.geoLocation); // km
      const timeDiff = (prev.timestamp.getTime() - curr.timestamp.getTime()) / (1000 * 60 * 60); // 시간
      const speed = distance / timeDiff; // km/h
      
      // 물리적으로 불가능한 이동 속도 (예: 500 km/h 이상)
      if (speed > 500) {
        suspicionScore += 5;
      }
    }
  }
  
  return {
    suspicious: suspicionScore > 5,
    suspicionScore,
    details: {
      uniqueIPs,
      requestFrequency,
      timeSpread
    }
  };
};

// 토큰 검증 미들웨어에 이상 징후 감지 통합
const enhancedTokenVerification = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
  }
  
  try {
    // 기본 토큰 검증
    const decoded = jwt.verify(token, JWT_SECRET);
    req.user = decoded;
    
    // 토큰 사용 로깅
    const tokenId = decoded.jti || crypto.createHash('md5').update(token).digest('hex');
    await logTokenUsage(decoded.id, tokenId, {
      ip: req.ip,
      userAgent: req.headers['user-agent'],
      endpoint: req.path,
      geoLocation: req.headers['x-geo-location'] ? JSON.parse(req.headers['x-geo-location']) : null
    });
    
    // 이상 징후 감지
    const abnormalUsage = await detectAbnormalTokenUsage(decoded.id);
    
    if (abnormalUsage.suspicious) {
      // 이상 징후가 감지되면 추가 검증 요구
      return res.status(403).json({
        message: '추가 인증이 필요합니다.',
        requireAdditionalAuth: true,
        reason: 'suspicious_activity'
      });
    }
    
    next();
  } catch (error) {
    return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
  }
};

리프레시 토큰 보안

리프레시 토큰은 수명이 긴 만큼 더 강력한 보안이 필요합니다.

 

1. 리프레시 토큰 순환 (Rotation)

리프레시 토큰을 사용할 때마다 새로운 리프레시 토큰을 발급하여 토큰 재사용을 방지합니다.

// 리프레시 토큰 사용 시 새 토큰 발급
const refreshTokens = async (refreshToken: string) => {
  try {
    // 토큰 검증
    const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET) as {
      id: number;
      family: string; // 토큰 패밀리 ID
      version: number; // 토큰 버전
    };
    
    // 데이터베이스에서 최신 토큰 정보 조회
    const tokenRecord = await db.refreshTokens.findUnique({
      where: {
        userId_family: {
          userId: decoded.id,
          family: decoded.family
        }
      }
    });
    
    // 토큰 기록이 없거나 버전이 맞지 않으면 거부 (토큰 재사용 시도)
    if (!tokenRecord || tokenRecord.version !== decoded.version) {
      // 토큰 재사용 감지 - 모든 패밀리 토큰 무효화
      await db.refreshTokens.deleteMany({
        where: { userId: decoded.id, family: decoded.family }
      });
      
      throw new Error('Refresh token reuse detected');
    }
    
    // 사용자 정보 조회
    const user = await findUserById(decoded.id);
    if (!user) {
      throw new Error('User not found');
    }
    
    // 새 액세스 토큰 생성
    const accessToken = generateAccessToken(user);
    
    // 새 리프레시 토큰 생성 (버전 증가)
    const newVersion = tokenRecord.version + 1;
    const newRefreshToken = jwt.sign(
      {
        id: user.id,
        family: decoded.family,
        version: newVersion
      },
      REFRESH_TOKEN_SECRET,
      { expiresIn: '7d' }
    );
    
    // 데이터베이스 업데이트
    await db.refreshTokens.update({
      where: {
        userId_family: {
          userId: user.id,
          family: decoded.family
        }
      },
      data: {
        token: hashToken(newRefreshToken), // 해시된 토큰만 저장
        version: newVersion,
        lastUsed: new Date()
      }
    });
    
    return {
      accessToken,
      refreshToken: newRefreshToken
    };
  } catch (error) {
    throw new Error(`Token refresh failed: ${error.message}`);
  }
};

// 토큰 해싱 함수
const hashToken = (token: string): string => {
  return crypto.createHash('sha256').update(token).digest('hex');
};

 

2. 리프레시 토큰 패밀리 관리

여러 디바이스에서 로그인한 경우를 효과적으로 관리하는 패밀리 기반 토큰 관리:

// 로그인 시 새 토큰 패밀리 생성
const createTokenFamily = async (user: User, deviceInfo: any) => {
  // 패밀리 ID 생성
  const familyId = crypto.randomBytes(16).toString('hex');
  
  // 액세스 토큰 생성
  const accessToken = generateAccessToken(user);
  
  // 리프레시 토큰 생성
  const refreshToken = jwt.sign(
    {
      id: user.id,
      family: familyId,
      version: 1
    },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );
  
  // 데이터베이스에 토큰 정보 저장
  await db.refreshTokens.create({
    data: {
      userId: user.id,
      family: familyId,
      token: hashToken(refreshToken),
      version: 1,
      deviceInfo: JSON.stringify(deviceInfo),
      createdAt: new Date(),
      lastUsed: new Date()
    }
  });
  
  return {
    accessToken,
    refreshToken
  };
};

// 특정 디바이스 로그아웃 (단일 토큰 패밀리 무효화)
const logoutDevice = async (userId: number, familyId: string) => {
  await db.refreshTokens.delete({
    where: {
      userId_family: {
        userId,
        family: familyId
      }
    }
  });
};

// 모든 디바이스 로그아웃 (전체 토큰 패밀리 무효화)
const logoutAllDevices = async (userId: number) => {
  await db.refreshTokens.deleteMany({
    where: { userId }
  });
};

// 활성 세션 조회
const getActiveSessions = async (userId: number) => {
  const tokenRecords = await db.refreshTokens.findMany({
    where: { userId }
  });
  
  return tokenRecords.map(record => ({
    family: record.family,
    deviceInfo: JSON.parse(record.deviceInfo),
    createdAt: record.createdAt,
    lastUsed: record.lastUsed
  }));
};

 

3. 리프레시 토큰 악용 감지

리프레시 토큰의 비정상적인 사용 패턴을 감지하고 대응합니다:

// 리프레시 토큰 사용 감지 및 대응
const detectRefreshTokenAbuse = async (userId: number) => {
  // 최근 리프레시 토큰 사용 기록 조회
  const recentRefreshes = await db.tokenUsageLogs.findMany({
    where: {
      userId,
      operation: 'REFRESH_TOKEN'
    },
    orderBy: { timestamp: 'desc' },
    take: 10
  });
  
  if (recentRefreshes.length < 3) return false;
  
  // 빠른 연속 리프레시 감지 (예: 10초 이내에 3회 이상)
  const now = new Date();
  const recentCount = recentRefreshes.filter(log => {
    const timeDiff = now.getTime() - log.timestamp.getTime();
    return timeDiff < 10 * 1000; // 10초
  }).length;
  
  if (recentCount >= 3) {
    // 잠재적 공격 감지 - 모든 리프레시 토큰 무효화
    await logoutAllDevices(userId);
    // 보안 알림 발송
    await sendSecurityAlert(userId, '비정상적인 토큰 갱신 시도가 감지되었습니다.');
    return true;
  }
  
  return false;
};

CSRF & XSS 공격 방어

 

1. CSRF(Cross-Site Request Forgery) 방어

CSRF 공격은 사용자가 인증된 상태에서 악의적인 사이트가 사용자 모르게 요청을 보내는 공격입니다.

// CSRF 토큰 생성 (서버 측)
const generateCsrfToken = (userId: number): string => {
  // 랜덤 토큰 생성
  const token = crypto.randomBytes(32).toString('hex');
  
  // Redis나 세션에 저장 (사용자 ID와 연결)
  redisClient.set(`csrf:${userId}:${token}`, '1', { EX: 3600 }); // 1시간 유효
  
  return token;
};

// Express 미들웨어로 CSRF 보호 구현
const csrfProtection = async (req, res, next) => {
  // GET 요청은 건너뜀
  if (req.method === 'GET') {
    return next();
  }
  
  // 사용자 ID
  const userId = req.user?.id;
  if (!userId) {
    return res.status(401).json({ message: '인증이 필요합니다.' });
  }
  
  // CSRF 토큰 확인
  const csrfToken = req.headers['x-csrf-token'];
  if (!csrfToken) {
    return res.status(403).json({ message: 'CSRF 토큰이 필요합니다.' });
  }
  
  // 토큰 유효성 확인
  const isValid = await redisClient.get(`csrf:${userId}:${csrfToken}`);
  if (!isValid) {
    return res.status(403).json({ message: '유효하지 않은 CSRF 토큰입니다.' });
  }
  
  // 토큰 사용 후 제거 (일회용)
  await redisClient.del(`csrf:${userId}:${csrfToken}`);
  
  next();
};

// 클라이언트 측에서 CSRF 토큰 관리
const setupCsrfProtection = () => {
  let csrfToken = null;
  
  // CSRF 토큰 가져오기
  const fetchCsrfToken = async () => {
    const response = await fetch('/api/auth/csrf-token');
    const data = await response.json();
    csrfToken = data.token;
  };
  
  // API 호출 시 CSRF 토큰 포함
  const api = axios.create({
    baseURL: '/api'
  });
  
  api.interceptors.request.use(async config => {
    // GET 요청이 아니면 CSRF 토큰 포함
    if (config.method !== 'get') {
      // 토큰이 없으면 가져오기
      if (!csrfToken) {
        await fetchCsrfToken();
      }
      
      config.headers['X-CSRF-Token'] = csrfToken;
      
      // 토큰 사용 후 새 토큰 가져오기
      fetchCsrfToken().catch(console.error);
    }
    
    return config;
  });
  
  return api;
};

 

2. XSS(Cross-Site Scripting) 방어

XSS 공격은 웹 사이트에 악성 스크립트를 삽입하여 사용자 세션을 탈취하는 공격입니다.

// 서버 측 응답 헤더 설정
app.use((req, res, next) => {
  // XSS 방어를 위한 Content-Security-Policy 설정
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' https://trusted-cdn.com; img-src 'self' data: https://trusted-cdn.com;"
  );
  
  // XSS 필터 활성화
  res.setHeader('X-XSS-Protection', '1; mode=block');
  
  // 콘텐츠 타입 스니핑 방지
  res.setHeader('X-Content-Type-Options', 'nosniff');
  
  // 클릭재킹 방지
  res.setHeader('X-Frame-Options', 'DENY');
  
  next();
});

// 클라이언트 측 입력 검증 및 정화
import DOMPurify from 'dompurify';

// 사용자 입력 정화 함수
const sanitizeInput = (input: string): string => {
  return DOMPurify.sanitize(input);
};

// React 컴포넌트에서 사용 예시
const CommentForm = () => {
  const [comment, setComment] = useState('');
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    
    // 입력 정화 후 API 호출
    const sanitizedComment = sanitizeInput(comment);
    
    await api.post('/comments', { content: sanitizedComment });
    setComment('');
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={comment}
        onChange={(e) => setComment(e.target.value)}
        placeholder="댓글을 입력하세요"
      />
      <button type="submit">등록</button>
    </form>
  );
};

블랙리스트와 화이트리스트 전략

JWT는 상태를 저장하지 않는(stateless) 특성을 가지고 있지만, 보안을 강화하기 위해 토큰 관리 전략을 구현할 수 있습니다.

 

1. 토큰 블랙리스트

로그아웃한 토큰이나 탈취된 토큰을 블랙리스트에 추가하여 사용을 방지합니다.

// Redis를 사용한 토큰 블랙리스트 구현
import { createClient } from 'redis';

const redisClient = createClient({
  url: process.env.REDIS_URL
});

redisClient.connect().catch(console.error);

// 토큰을 블랙리스트에 추가
const blacklistToken = async (token: string) => {
  try {
    // 토큰 디코딩하여 만료 시간 확인
    const decoded = jwt.decode(token) as { exp: number };
    
    if (!decoded || !decoded.exp) {
      throw new Error('Invalid token format');
    }
    
    const now = Math.floor(Date.now() / 1000);
    const ttl = Math.max(0, decoded.exp - now); // 남은 시간(초)
    
    // 토큰 해시를 블랙리스트에 추가
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
    await redisClient.set(`bl:${tokenHash}`, '1', { EX: ttl });
    
    return true;
  } catch (error) {
    console.error('블랙리스트 추가 실패:', error);
    return false;
  }
};

// 토큰이 블랙리스트에 있는지 확인
const isTokenBlacklisted = async (token: string): Promise<boolean> => {
  try {
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
    const result = await redisClient.get(`bl:${tokenHash}`);
    return result !== null;
  } catch (error) {
    console.error('블랙리스트 확인 실패:', error);
    return false; // 오류 발생 시 기본적으로 블랙리스트에 없다고 간주
  }
};

// 로그아웃 API 구현
app.post('/api/auth/logout', authenticateToken, async (req, res) => {
  try {
    const token = req.headers.authorization?.split(' ')[1];
    
    if (token) {
      // 토큰을 블랙리스트에 추가
      await blacklistToken(token);
    }
    
    // 리프레시 토큰 쿠키 제거
    res.clearCookie('refreshToken');
    
    res.status(200).json({ message: '로그아웃 성공' });
  } catch (error) {
    res.status(500).json({ message: '로그아웃 중 오류가 발생했습니다.' });
  }
});

// 토큰 검증 미들웨어에 블랙리스트 확인 추가
const authenticateToken = async (req, res, next) => {
  const authHeader = req.headers.authorization;
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
  }
  
  try {
    // 블랙리스트 확인
    const blacklisted = await isTokenBlacklisted(token);
    if (blacklisted) {
      return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
    }
    
    // 토큰 검증
    const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
  }
};

블랙리스트 방식의 장단점:

장점:

  • 필요할 때 즉시 토큰을 무효화할 수 있습니다.
  • 토큰 탈취가 의심될 때 빠르게 대응할 수 있습니다.

단점:

  • 캐시 서버(Redis 등)가 필요하여 인프라 복잡성이 증가합니다.
  • 상태 저장이 필요하여 JWT의 상태 비저장(stateless) 장점이 일부 상실됩니다.
  • 모든 요청마다 블랙리스트 확인이 필요하여 성능에 영향을 줄 수 있습니다.

 

2. 토큰 화이트리스트

허용된 토큰만 유효하게 인정하는 화이트리스트 방식입니다.

// Redis를 사용한 토큰 화이트리스트 구현
// 사용자 로그인 시 토큰을 화이트리스트에 추가
const whitelistToken = async (userId: number, token: string, deviceInfo: any) => {
  try {
    // 토큰 디코딩하여 만료 시간 확인
    const decoded = jwt.decode(token) as { exp: number; jti: string };
    
    if (!decoded || !decoded.exp || !decoded.jti) {
      throw new Error('Invalid token format');
    }
    
    const now = Math.floor(Date.now() / 1000);
    const ttl = Math.max(0, decoded.exp - now); // 남은 시간(초)
    
    // 토큰 정보를 화이트리스트에 추가
    await redisClient.set(
      `wl:${userId}:${decoded.jti}`,
      JSON.stringify({
        device: deviceInfo.name,
        ip: deviceInfo.ip,
        createdAt: new Date().toISOString()
      }),
      { EX: ttl }
    );
    
    return true;
  } catch (error) {
    console.error('화이트리스트 추가 실패:', error);
    return false;
  }
};

// 토큰이 화이트리스트에 있는지 확인
const isTokenWhitelisted = async (userId: number, tokenId: string): Promise<boolean> => {
  try {
    const result = await redisClient.get(`wl:${userId}:${tokenId}`);
    return result !== null;
  } catch (error) {
    console.error('화이트리스트 확인 실패:', error);
    return false;
  }
};

// 로그인 API 구현 (화이트리스트 적용)
app.post('/api/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // 사용자 인증
    const user = await authenticateUser(email, password);
    
    if (!user) {
      return res.status(401).json({ message: '이메일 또는 비밀번호가 잘못되었습니다.' });
    }
    
    // 고유 토큰 ID 생성
    const tokenId = crypto.randomBytes(16).toString('hex');
    
    // 토큰 생성 (토큰 ID 포함)
    const accessToken = jwt.sign(
      { id: user.id, role: user.role, jti: tokenId },
      JWT_SECRET,
      { expiresIn: '15m' }
    );
    
    // 디바이스 정보 수집
    const deviceInfo = {
      name: req.headers['user-agent'] || 'unknown',
      ip: req.ip
    };
    
    // 토큰을 화이트리스트에 추가
    await whitelistToken(user.id, accessToken, deviceInfo);
    
    // 리프레시 토큰 생성 및 쿠키 설정
    const refreshToken = generateRefreshToken(user);
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000 // 7일
    });
    
    res.status(200).json({
      accessToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role
      }
    });
  } catch (error) {
    res.status(500).json({ message: '로그인 중 오류가 발생했습니다.' });
  }
});

// 토큰 검증 미들웨어에 화이트리스트 확인 추가
const authenticateToken = async (req, res, next) => {
  const authHeader = req.headers.authorization;
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
  }
  
  try {
    // 토큰 검증
    const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as {
      id: number;
      jti: string;
    };
    
    // 화이트리스트 확인
    const whitelisted = await isTokenWhitelisted(decoded.id, decoded.jti);
    if (!whitelisted) {
      return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
    }
    
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
  }
};

화이트리스트 방식의 장단점:

장점:

  • 모든 유효한 토큰을 명시적으로 관리할 수 있습니다.
  • 특정 사용자의 모든 세션을 쉽게 관리할 수 있습니다.
  • 토큰 발급 및 사용에 대한 상세한 모니터링이 가능합니다.

단점:

  • 모든 토큰에 대한 저장소가 필요하여 리소스 사용량이 증가합니다.
  • JWT의 상태 비저장(stateless) 특성이 완전히 상실됩니다.
  • 매 요청마다 데이터베이스 조회가 필요하여 성능 저하가 발생할 수 있습니다.

 

3. 하이브리드 접근법

블랙리스트와 화이트리스트의 장점을 결합한 하이브리드 접근법입니다.

// 리프레시 토큰은 화이트리스트에 저장
// 액세스 토큰은 필요한 경우만 블랙리스트에 추가

// 리프레시 토큰 화이트리스트에 추가
const whitelistRefreshToken = async (userId: number, token: string, deviceInfo: any) => {
  try {
    // 토큰 해시 생성
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
    
    // 이전 토큰 정보 가져오기
    const previousTokens = await redisClient.lRange(`wl:refresh:${userId}`, 0, -1);
    
    // 최대 디바이스 수 제한 (예: 5개)
    if (previousTokens.length >= 5) {
      // 가장 오래된 토큰 제거
      const oldestToken = await redisClient.lPop(`wl:refresh:${userId}`);
      if (oldestToken) {
        await redisClient.del(`wl:refresh:data:${oldestToken}`);
      }
    }
    
    // 토큰 정보 저장
    await redisClient.set(
      `wl:refresh:data:${tokenHash}`,
      JSON.stringify({
        userId,
        device: deviceInfo.name,
        ip: deviceInfo.ip,
        createdAt: new Date().toISOString()
      }),
      { EX: 60 * 60 * 24 * 7 } // 7일
    );
    
    // 사용자의 토큰 리스트에 추가
    await redisClient.rPush(`wl:refresh:${userId}`, tokenHash);
    await redisClient.expire(`wl:refresh:${userId}`, 60 * 60 * 24 * 30); // 30일
    
    return true;
  } catch (error) {
    console.error('리프레시 토큰 화이트리스트 추가 실패:', error);
    return false;
  }
};

// 리프레시 토큰이 화이트리스트에 있는지 확인
const isRefreshTokenWhitelisted = async (token: string): Promise<any> => {
  try {
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
    const result = await redisClient.get(`wl:refresh:data:${tokenHash}`);
    
    if (!result) return null;
    
    return JSON.parse(result);
  } catch (error) {
    console.error('리프레시 토큰 화이트리스트 확인 실패:', error);
    return null;
  }
};

// 보안 이슈가 있는 경우에만 액세스 토큰 블랙리스트에 추가
const blacklistAccessToken = async (token: string) => {
  try {
    // 토큰 디코딩
    const decoded = jwt.decode(token) as { exp: number };
    
    if (!decoded || !decoded.exp) {
      throw new Error('Invalid token format');
    }
    
    const now = Math.floor(Date.now() / 1000);
    const ttl = Math.max(0, decoded.exp - now); // 남은 시간(초)
    
    // 만료까지 5분 미만인 경우 블랙리스트에 추가하지 않음
    if (ttl < 300) {
      return true; // 곧 만료될 토큰은 블랙리스트에 추가할 필요 없음
    }
    
    // 토큰 해시를 블랙리스트에 추가
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
    await redisClient.set(`bl:${tokenHash}`, '1', { EX: ttl });
    
    return true;
  } catch (error) {
    console.error('블랙리스트 추가 실패:', error);
    return false;
  }
};

// 토큰 갱신 구현
app.post('/api/auth/refresh', async (req, res) => {
  try {
    // 쿠키에서 리프레시 토큰 추출
    const refreshToken = req.cookies.refreshToken;
    
    if (!refreshToken) {
      return res.status(401).json({ message: '리프레시 토큰이 필요합니다.' });
    }
    
    // 리프레시 토큰이 화이트리스트에 있는지 확인
    const tokenData = await isRefreshTokenWhitelisted(refreshToken);
    
    if (!tokenData) {
      return res.status(401).json({ message: '유효하지 않은 리프레시 토큰입니다.' });
    }
    
    // 리프레시 토큰 검증
    const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET) as { id: number };
    
    // 토큰 데이터와 디코딩된 사용자 ID 일치 확인
    if (tokenData.userId !== decoded.id) {
      return res.status(401).json({ message: '토큰 정보가 일치하지 않습니다.' });
    }
    
    // 사용자 정보 조회
    const user = await findUserById(decoded.id);
    
    if (!user) {
      return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
    }
    
    // 새 액세스 토큰 생성
    const accessToken = jwt.sign(
      { id: user.id, role: user.role },
      JWT_SECRET,
      { expiresIn: '15m' }
    );
    
    // 디바이스 정보 업데이트
    const deviceInfo = {
      name: req.headers['user-agent'] || 'unknown',
      ip: req.ip
    };
    
    // 리프레시 토큰 정보 업데이트 (마지막 사용 시간)
    const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
    await redisClient.set(
      `wl:refresh:data:${tokenHash}`,
      JSON.stringify({
        ...tokenData,
        lastUsed: new Date().toISOString(),
        ip: deviceInfo.ip // IP 업데이트
      }),
      { EX: 60 * 60 * 24 * 7 } // 7일
    );
    
    res.status(200).json({ accessToken });
  } catch (error) {
    res.status(401).json({ message: '토큰 갱신에 실패했습니다.' });
  }
});

// 보안 이슈 발생 시 모든 토큰 무효화
const invalidateAllTokens = async (userId: number) => {
  try {
    // 1. 모든 리프레시 토큰 조회
    const tokenHashes = await redisClient.lRange(`wl:refresh:${userId}`, 0, -1);
    
    // 2. 각 리프레시 토큰 데이터 삭제
    for (const hash of tokenHashes) {
      await redisClient.del(`wl:refresh:data:${hash}`);
    }
    
    // 3. 리프레시 토큰 리스트 삭제
    await redisClient.del(`wl:refresh:${userId}`);
    
    // 4. 사용자 버전 증가 (선택적)
    await updateUserTokenVersion(userId);
    
    return true;
  } catch (error) {
    console.error('토큰 무효화 실패:', error);
    return false;
  }
};

하이브리드 접근법의 장점:

  • 리프레시 토큰은 화이트리스트로 엄격하게 관리하여 장기적인 보안 강화
  • 액세스 토큰은 짧은 수명으로 유지하고 필요한 경우에만 블랙리스트에 추가하여 성능 최적화
  • 사용자별 활성 세션 관리 기능 제공
  • 보안 이슈 발생 시 효과적인 대응 가능

JWT 서명 알고리즘 강화

JWT 보안에서 가장 중요한 요소 중 하나는 적절한 서명 알고리즘 선택입니다.

 

1. 대칭키 vs 비대칭키 알고리즘

대칭키 알고리즘 (HMAC):

  • 같은 비밀 키로 서명과 검증을 모두 수행
  • 구현이 간단하고 처리 속도가 빠름
  • 모든 서버가 같은 비밀 키를 공유해야 함
// 대칭키 알고리즘 (HMAC) 사용 예시
const generateHmacToken = (payload: any): string => {
  return jwt.sign(payload, SECRET_KEY, {
    algorithm: 'HS256', // HMAC + SHA256
    expiresIn: '15m'
  });
};

const verifyHmacToken = (token: string): any => {
  return jwt.verify(token, SECRET_KEY, {
    algorithms: ['HS256'] // 허용할 알고리즘 명시적 지정
  });
};

비대칭키 알고리즘 (RSA/ECDSA):

  • 공개 키로 검증하고 개인 키로 서명
  • 개인 키는 인증 서버에만 저장하고 공개 키는 리소스 서버에 배포 가능
  • 마이크로서비스 아키텍처에 적합
  • 키 관리가 복잡하고 처리 속도가 상대적으로 느림
// 비대칭키 알고리즘 (RSA) 사용 예시
import { readFileSync } from 'fs';

// 키 로드
const privateKey = readFileSync('private_key.pem');
const publicKey = readFileSync('public_key.pem');

const generateRsaToken = (payload: any): string => {
  return jwt.sign(payload, privateKey, {
    algorithm: 'RS256', // RSA + SHA256
    expiresIn: '15m'
  });
};

const verifyRsaToken = (token: string): any => {
  return jwt.verify(token, publicKey, {
    algorithms: ['RS256'] // 허용할 알고리즘 명시적 지정
  });
};

 

2. 키 순환(Key Rotation) 구현

보안 강화를 위해 주기적으로 서명 키를 변경하는 전략입니다.

// 키 관리 서비스 구현
class KeyManager {
  private keys: Map<string, { key: string; expiresAt: Date }> = new Map();
  private currentKeyId: string;
  
  constructor() {
    // 초기 키 생성
    this.rotateKeys();
  }
  
  // 새 키 생성 및 교체
  rotateKeys() {
    const keyId = crypto.randomBytes(8).toString('hex');
    const key = crypto.randomBytes(32).toString('hex');
    const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30일 후
    
    this.keys.set(keyId, { key, expiresAt });
    this.currentKeyId = keyId;
    
    // 만료된 키 제거
    for (const [id, keyData] of this.keys.entries()) {
      if (keyData.expiresAt < new Date()) {
        this.keys.delete(id);
      }
    }
    
    // 30일마다 자동 교체
    setTimeout(() => this.rotateKeys(), 30 * 24 * 60 * 60 * 1000);
    
    return { keyId, key };
  }
  
  // 현재 활성 키 가져오기
  getCurrentKey() {
    const keyData = this.keys.get(this.currentKeyId);
    return { keyId: this.currentKeyId, key: keyData?.key };
  }
  
  // 특정 ID의 키 가져오기
  getKey(keyId: string) {
    return this.keys.get(keyId)?.key;
  }
}

// 키 관리자 인스턴스 생성
const keyManager = new KeyManager();

// 키 순환을 적용한 토큰 생성
const generateTokenWithKeyRotation = (payload: any): string => {
  const { keyId, key } = keyManager.getCurrentKey();
  
  const token = jwt.sign(
    { ...payload, kid: keyId }, // Key ID를 페이로드에 포함
    key,
    { algorithm: 'HS256', expiresIn: '15m' }
  );
  
  return token;
};

// 키 순환을 적용한 토큰 검증
const verifyTokenWithKeyRotation = (token: string): any => {
  // 토큰에서 헤더 추출
  const segments = token.split('.');
  if (segments.length !== 3) {
    throw new Error('Invalid token format');
  }
  
  // 헤더 디코딩
  const header = JSON.parse(Buffer.from(segments[0], 'base64').toString());
  
  // 키 ID 확인
  const keyId = header.kid;
  if (!keyId) {
    throw new Error('Key ID is missing');
  }
  
  // 키 가져오기
  const key = keyManager.getKey(keyId);
  if (!key) {
    throw new Error('Invalid Key ID');
  }
  
  // 토큰 검증
  return jwt.verify(token, key, { algorithms: ['HS256'] });
};

 

3. EdDSA 알고리즘 활용

최신 암호화 알고리즘인 EdDSA(Edwards-curve Digital Signature Algorithm)는 RSA보다 더 작은 키 크기로 동등한 보안을 제공합니다.

// EdDSA 알고리즘 사용
import crypto from 'crypto';

// 키 쌍 생성
const generateKeyPair = () => {
  return crypto.generateKeyPairSync('ed25519', {
    publicKeyEncoding: {
      type: 'spki',
      format: 'pem'
    },
    privateKeyEncoding: {
      type: 'pkcs8',
      format: 'pem'
    }
  });
};

const { privateKey, publicKey } = generateKeyPair();

// EdDSA를 사용한 JWT 생성
const generateEdDSAToken = (payload: any): string => {
  return jwt.sign(payload, privateKey, {
    algorithm: 'EdDSA',
    expiresIn: '15m'
  });
};

// EdDSA를 사용한 JWT 검증
const verifyEdDSAToken = (token: string): any => {
  return jwt.verify(token, publicKey, {
    algorithms: ['EdDSA']
  });
};

고급 JWT 보안 기술

 

1. 이중 토큰 검증

중요한 작업(비밀번호 변경, 결제, 개인정보 수정 등)에 대해서는 이중 토큰 검증을 구현하여 보안을 강화합니다.

// 중요 작업용 특수 토큰 생성
const generateCriticalActionToken = (userId: number, action: string): string => {
  return jwt.sign(
    {
      sub: userId,
      action,
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 10 * 60 // 10분 유효
    },
    CRITICAL_ACTION_SECRET
  );
};

// 중요 작업 토큰 요청 API
app.post('/api/auth/action-token', authenticateToken, async (req, res) => {
  try {
    const { action } = req.body;
    const userId = req.user.id;
    
    // 허용된 액션 목록
    const allowedActions = ['change-password', 'update-email', 'payment', 'delete-account'];
    
    if (!allowedActions.includes(action)) {
      return res.status(400).json({ message: '유효하지 않은 액션입니다.' });
    }
    
    // 특수 토큰 생성
    const actionToken = generateCriticalActionToken(userId, action);
    
    // 선택적: 이메일이나 SMS로 토큰 사용 알림 발송
    await sendSecurityAlert(userId, `중요 작업 "${action}" 수행을 위한 토큰이 발급되었습니다.`);
    
    res.status(200).json({ actionToken });
  } catch (error) {
    res.status(500).json({ message: '토큰 생성 중 오류가 발생했습니다.' });
  }
});

// 중요 작업 수행 API (이중 토큰 검증)
app.post('/api/users/change-password', authenticateToken, async (req, res) => {
  try {
    const { currentPassword, newPassword, actionToken } = req.body;
    const userId = req.user.id;
    
    // 1. 액션 토큰 검증
    if (!actionToken) {
      return res.status(403).json({ message: '중요 작업 토큰이 필요합니다.' });
    }
    
    try {
      const decoded = jwt.verify(actionToken, CRITICAL_ACTION_SECRET) as {
        sub: number;
        action: string;
      };
      
      // 토큰의 사용자 ID와 현재 사용자 ID 일치 확인
      if (decoded.sub !== userId) {
        throw new Error('User ID mismatch');
      }
      
      // 토큰의 액션 검증
      if (decoded.action !== 'change-password') {
        throw new Error('Invalid action');
      }
    } catch (error) {
      return res.status(403).json({ message: '유효하지 않은 작업 토큰입니다.' });
    }
    
    // 2. 비밀번호 변경 로직
    const user = await findUserById(userId);
    
    // 현재 비밀번호 확인
    const passwordValid = await bcrypt.compare(currentPassword, user.password);
    if (!passwordValid) {
      return res.status(400).json({ message: '현재 비밀번호가 일치하지 않습니다.' });
    }
    
    // 새 비밀번호 해시
    const hashedPassword = await bcrypt.hash(newPassword, 10);
    
    // 비밀번호 업데이트
    await updateUserPassword(userId, hashedPassword);
    
    // 모든 리프레시 토큰 무효화
    await invalidateAllRefreshTokens(userId);
    
    res.status(200).json({ message: '비밀번호가 성공적으로 변경되었습니다.' });
  } catch (error) {
    res.status(500).json({ message: '비밀번호 변경 중 오류가 발생했습니다.' });
  }
});

 

2. JWT 수직 및 수평 확장(Vertical and Horizontal Scaling)

많은 요청을 처리해야 하는 대규모 시스템에서의 JWT 활용 전략입니다.

// Redis를 사용한 JWT 캐싱 구현
import { createClient } from 'redis';

// Redis 클라이언트 생성
const redisClient = createClient({
  url: process.env.REDIS_URL
});

redisClient.connect().catch(console.error);

// 블랙리스트 캐싱용 프리픽스
const BLACKLIST_PREFIX = 'bl:';
// 사용자 정보 캐싱용 프리픽스
const USER_PREFIX = 'user:';

// 토큰 검증 미들웨어 (캐싱 적용)
const authenticateToken = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
  }
  
  try {
    // 1. 블랙리스트 캐시 확인
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
    const blacklisted = await redisClient.get(`${BLACKLIST_PREFIX}${tokenHash}`);
    
    if (blacklisted) {
      return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
    }
    
    // 2. 토큰 검증
    const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as {
      id: number;
      role: string;
    };
    
    // 3. 사용자 정보 캐시 확인
    const cachedUser = await redisClient.get(`${USER_PREFIX}${decoded.id}`);
    
    if (cachedUser) {
      // 캐시된 사용자 정보 사용
      req.user = JSON.parse(cachedUser);
    } else {
      // DB에서 사용자 정보 조회
      const user = await findUserById(decoded.id);
      
      if (!user) {
        return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
      }
      
      // 사용자 정보 캐싱 (민감한 정보 제외)
      const userToCache = {
        id: user.id,
        name: user.name,
        email: user.email,
        role: user.role
      };
      
      await redisClient.set(
        `${USER_PREFIX}${decoded.id}`,
        JSON.stringify(userToCache),
        { EX: 60 * 60 } // 1시간 캐싱
      );
      
      req.user = userToCache;
    }
    
    next();
  } catch (error) {
    return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
  }
};

// 토큰 생성 시 부하 분산
const generateLoadBalancedToken = (user: User) => {
  // 서버 인스턴스마다 다른 시크릿 키를 사용하는 대신
  // 공유 시크릿 키 사용 (모든 서버가 검증 가능)
  return jwt.sign(
    { id: user.id, role: user.role },
    SHARED_JWT_SECRET,
    { expiresIn: '15m' }
  );
};

 

3. 트래픽 서명(Traffic Signing)

API 요청/응답에 추가적인 서명을 포함하여 중간자 공격을 방지합니다.

// 클라이언트 측 요청 서명
const signRequest = (method: string, url: string, body: any, timestamp: number, nonce: string) => {
  // 서명할 데이터 준비
  const dataToSign = `${method.toUpperCase()}:${url}:${JSON.stringify(body)}:${timestamp}:${nonce}`;
  
  // HMAC-SHA256으로 서명
  const signature = crypto
    .createHmac('sha256', CLIENT_SECRET)
    .update(dataToSign)
    .digest('hex');
  
  return signature;
};

// 요청에 서명 추가
const api = axios.create({
  baseURL: '/api'
});

api.interceptors.request.use(config => {
  const nonce = crypto.randomBytes(16).toString('hex');
  const timestamp = Date.now();
  
  // 요청 서명 생성
  const signature = signRequest(
    config.method || 'get',
    config.url || '',
    config.data,
    timestamp,
    nonce
  );
  
  // 헤더에 서명 정보 추가
  config.headers['X-Request-Signature'] = signature;
  config.headers['X-Request-Timestamp'] = timestamp;
  config.headers['X-Request-Nonce'] = nonce;
  
  return config;
});

// 서버 측 요청 서명 검증
const verifyRequestSignature = (req, res, next) => {
  const signature = req.headers['x-request-signature'];
  const timestamp = parseInt(req.headers['x-request-timestamp']);
  const nonce = req.headers['x-request-nonce'];
  
  // 필수 헤더 확인
  if (!signature || !timestamp || !nonce) {
    return res.status(401).json({ message: '서명이 필요합니다.' });
  }
  
  // 타임스탬프 검증 (5분 이내)
  const now = Date.now();
  if (now - timestamp > 5 * 60 * 1000) {
    return res.status(401).json({ message: '만료된 요청입니다.' });
  }
  
  // 서명할 데이터 준비
  const dataToSign = `${req.method.toUpperCase()}:${req.path}:${JSON.stringify(req.body)}:${timestamp}:${nonce}`;
  
  // HMAC-SHA256으로 서명 검증
  const expectedSignature = crypto
    .createHmac('sha256', CLIENT_SECRET)
    .update(dataToSign)
    .digest('hex');
  
  // 서명 일치 확인
  if (signature !== expectedSignature) {
    return res.status(401).json({ message: '유효하지 않은 서명입니다.' });
  }
  
  // 논스(nonce) 재사용 방지
  const nonceKey = `nonce:${nonce}`;
  redisClient.get(nonceKey).then(exists => {
    if (exists) {
      return res.status(401).json({ message: '이미 사용된 논스입니다.' });
    }
    
    // 논스 사용 기록 (1시간 동안 유지)
    redisClient.set(nonceKey, '1', { EX: 60 * 60 });
    next();
  }).catch(error => {
    console.error('논스 확인 중 오류:', error);
    return res.status(500).json({ message: '서버 오류가 발생했습니다.' });
  });
};

 

4. 분산 환경에서의 JWT 관리

마이크로서비스 아키텍처에서 JWT를 효과적으로 관리하는 전략입니다.

// 공개/개인 키 쌍 사용
import { readFileSync } from 'fs';
import { JWK, JWS } from 'node-jose';

// 인증 서비스에서만 사용하는 개인 키
const privateKey = readFileSync('private_key.pem');
// 모든 서비스에서 공유하는 공개 키
const publicKey = readFileSync('public_key.pem');

// 키스토어 생성
const keystore = JWK.createKeyStore();

// RSA 키 가져오기
const setupKeys = async () => {
  await keystore.add(privateKey, 'pem');
};

// 토큰 발급 (인증 서비스에서만 수행)
const generateServiceToken = async (payload: any) => {
  const key = keystore.all({ use: 'sig' })[0];
  
  // JWS(JSON Web Signature) 생성
  const signed = await JWS.createSign({ format: 'compact' }, key)
    .update(JSON.stringify({
      ...payload,
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 15 * 60 // 15분
    }))
    .final();
  
  return signed;
};

// 토큰 검증 (모든 서비스에서 수행 가능)
const verifyServiceToken = async (token: string) => {
  try {
    const result = await JWS.createVerify()
      .use(publicKey)
      .verify(token);
    
    const payload = JSON.parse(result.payload.toString());
    
    // 만료 시간 확인
    const now = Math.floor(Date.now() / 1000);
    if (payload.exp <= now) {
      throw new Error('Token expired');
    }
    
    return payload;
  } catch (error) {
    throw new Error(`Token verification failed: ${error.message}`);
  }
};

// 서비스 간 인증 미들웨어
const authenticateServiceRequest = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
  }
  
  try {
    const payload = await verifyServiceToken(token);
    req.serviceContext = payload;
    next();
  } catch (error) {
    return res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
  }
};

 

5. JWT 요청 제한 및 스로틀링

토큰 기반으로 API 요청 횟수를 제한하여 무차별 대입 공격을 방지합니다.

// Redis를 사용한 요청 제한 구현
import { RateLimiterRedis } from 'rate-limiter-flexible';

// Redis 클라이언트
const redisClient = createClient({
  url: process.env.REDIS_URL
});

redisClient.connect().catch(console.error);

// 일반 사용자용 레이트 리미터
const userRateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'ratelimit:user',
  points: 100, // 허용 요청 수
  duration: 60, // 60초 동안
  blockDuration: 60 * 10 // 제한 초과 시 10분 차단
});

// 관리자용 레이트 리미터 (더 높은 제한)
const adminRateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'ratelimit:admin',
  points: 500, // 더 많은 요청 허용
  duration: 60
});

// 토큰 기반 요청 제한 미들웨어
const rateLimitByToken = async (req, res, next) => {
  try {
    // 토큰에서 사용자 정보 추출
    const user = req.user;
    
    if (!user) {
      return next(); // 인증되지 않은 요청은 다른 미들웨어에서 처리
    }
    
    // 사용자 역할에 따라 적절한 레이트 리미터 선택
    const limiter = user.role === 'admin' ? adminRateLimiter : userRateLimiter;
    
    // 사용자 ID 기반으로 제한 적용
    await limiter.consume(user.id.toString());
    next();
  } catch (error) {
    // 요청 제한 초과
    if (error.remainingPoints !== undefined) {
      return res.status(429).json({
        message: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.',
        retryAfter: Math.round(error.msBeforeNext / 1000) || 1
      });
    }
    
    // 다른 오류
    console.error('레이트 리밋 오류:', error);
    next(error);
  }
};

// 라우트에 미들웨어 적용 예시
app.post('/api/sensitive-operation', 
  authenticateToken, // 먼저 토큰 검증
  rateLimitByToken, // 그 다음 요청 제한 적용
  async (req, res) => {
    // 비즈니스 로직
  }
);

 

6. Proof of Possession(PoP) 토큰

클라이언트가 토큰을 합법적으로 소유하고 있음을 증명하는 방식입니다.

// PoP 토큰 발급
const issuePopToken = async (userId: number) => {
  // 일회용 키 쌍 생성 (클라이언트에서 생성해야 하지만, 예시를 위해 서버에서 생성)
  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: {
      type: 'spki',
      format: 'pem'
    },
    privateKeyEncoding: {
      type: 'pkcs8',
      format: 'pem'
    }
  });
  
  // 공개 키 해시 (토큰의 고유 식별자로 사용)
  const keyThumbprint = crypto
    .createHash('sha256')
    .update(publicKey)
    .digest('base64url');
  
  // PoP 토큰 생성
  const popToken = jwt.sign(
    {
      sub: userId,
      cnf: {
        jwk: {
          kty: 'RSA',
          use: 'sig',
          kid: keyThumbprint
        }
      }
    },
    JWT_SECRET,
    { expiresIn: '1h' }
  );
  
  return {
    popToken,
    privateKey,
    keyThumbprint
  };
};

// 클라이언트 측: PoP 증명 생성
const createPopProof = (url: string, method: string, privateKey: string, keyThumbprint: string) => {
  // 타임스탬프와 논스
  const timestamp = Date.now();
  const nonce = crypto.randomBytes(16).toString('hex');
  
  // 서명할 데이터
  const dataToSign = `${method.toUpperCase()}:${url}:${timestamp}:${nonce}`;
  
  // 개인 키로 서명
  const signature = crypto.sign(
    'sha256',
    Buffer.from(dataToSign),
    privateKey
  ).toString('base64');
  
  return {
    kid: keyThumbprint,
    timestamp,
    nonce,
    signature
  };
};

// 서버 측: PoP 증명 검증
const verifyPopProof = async (req, res, next) => {
  try {
    // 필수 헤더 확인
    const popToken = req.headers['x-pop-token'];
    const popProof = req.headers['x-pop-proof'];
    
    if (!popToken || !popProof) {
      return res.status(401).json({ message: 'PoP 증명이 필요합니다.' });
    }
    
    // PoP 토큰 검증
    const decodedToken = jwt.verify(popToken, JWT_SECRET) as {
      sub: number;
      cnf: {
        jwk: {
          kid: string;
        }
      }
    };
    
    // PoP 증명 파싱
    const proof = JSON.parse(popProof);
    
    // 토큰과 증명의 kid 일치 확인
    if (decodedToken.cnf.jwk.kid !== proof.kid) {
      throw new Error('Key ID mismatch');
    }
    
    // 논스 중복 사용 확인
    const nonceKey = `nonce:${proof.nonce}`;
    const nonceExists = await redisClient.get(nonceKey);
    
    if (nonceExists) {
      throw new Error('Nonce already used');
    }
    
    // 타임스탬프 검증 (5분 이내)
    const now = Date.now();
    if (now - proof.timestamp > 5 * 60 * 1000) {
      throw new Error('Timestamp expired');
    }
    
    // 공개 키 조회 (실제로는 DB나 키 저장소에서 조회)
    const publicKey = await getPublicKeyByThumbprint(proof.kid);
    
    // 서명할 데이터 재구성
    const dataToVerify = `${req.method.toUpperCase()}:${req.path}:${proof.timestamp}:${proof.nonce}`;
    
    // 서명 검증
    const isValid = crypto.verify(
      'sha256',
      Buffer.from(dataToVerify),
      publicKey,
      Buffer.from(proof.signature, 'base64')
    );
    
    if (!isValid) {
      throw new Error('Invalid signature');
    }
    
    // 논스 사용 기록
    await redisClient.set(nonceKey, '1', { EX: 60 * 60 });
    
    // 사용자 정보 설정
    req.user = { id: decodedToken.sub };
    next();
  } catch (error) {
    return res.status(401).json({ message: `PoP 검증 실패: ${error.message}` });
  }
};

JWT 보안 체크리스트

최종 정리를 위한 JWT 보안 체크리스트입니다.

1. 토큰 구성 및 서명

  • 적절한 JWT 서명 알고리즘 사용 (HS256, RS256, ES256 등)
  • 서명 알고리즘을 명시적으로 지정하여 알고리즘 변조 공격 방지
  • 강력한 비밀 키 또는 키 쌍 사용 (최소 256비트 이상)
  • 주기적인 키 순환(rotation) 구현
  • 비대칭 알고리즘을 사용할 경우 개인 키 안전하게 보관

2. 토큰 페이로드

  • 최소한의 필요 정보만 토큰에 포함
  • 민감한 정보(비밀번호, 개인정보 등) 포함 금지
  • 표준 클레임 활용 (iss, sub, exp, iat, jti 등)
  • 적절한 만료 시간(exp) 설정 (액세스 토큰 15분 내외)
  • 토큰 식별자(jti) 추가하여 토큰 추적 및 무효화 가능하게 설정

3. 토큰 저장 및 전송

  • 액세스 토큰은 메모리에 저장 (웹 스토리지 사용 지양)
  • 리프레시 토큰은 HttpOnly, Secure, SameSite 쿠키로 저장
  • HTTPS를 통해서만 토큰 전송
  • Authorization 헤더를 통한 토큰 전송
  • 쿠키 사용 시 적절한 CSRF 방어 구현

4. 토큰 검증

  • 모든 보호된 라우트에서 토큰 검증
  • 토큰 서명 검증
  • 만료 시간(exp) 검증
  • 발급자(iss) 검증
  • 필요시 블랙리스트 확인
  • 중요 작업 시 추가 검증 메커니즘 구현

5. 토큰 갱신 및 무효화

  • 리프레시 토큰 순환(rotation) 구현
  • 리프레시 토큰 사용 시 액세스 토큰만 발급 (리프레시 토큰은 필요 시에만 교체)
  • 리프레시 토큰 사용 추적 및 재사용 감지
  • 보안 이슈 발생 시 모든 토큰 무효화 메커니즘 구현
  • 로그아웃 시 적절한 토큰 무효화 처리

6. 부가 보안 조치

  • 요청량 제한(Rate limiting) 구현
  • 의심스러운 활동 모니터링 및 자동 대응
  • 보안 헤더 설정 (CSP, X-XSS-Protection 등)
  • 중요 작업에 이중 인증(2FA) 또는 이중 토큰 검증 적용
  • 요청 서명 또는 PoP(Proof of Possession) 메커니즘 구현

마무리

이번 JWT 인증 시리즈에서는 JWT(JSON Web Token)의 기본 개념부터 고급 보안 기법까지 포괄적으로 다루었습니다. JWT는 현대 웹 애플리케이션에서 인증을 구현하는 강력한 도구이지만, 올바르게 구현하지 않으면 심각한 보안 위협을 초래할 수 있습니다.

미래 발전 방향

JWT 인증 기술은 계속 발전하고 있으며, 다음과 같은 방향으로 확장될 수 있습니다:

  1. 향상된 사용자 경험
    • 컨텍스트 기반 토큰 수명 관리
    • 비침투적인 토큰 갱신 메커니즘
    • 진보된 세션 관리 기술
  2. 보안 강화
    • 행동 기반 이상 감지
    • 머신러닝을 활용한 이상 패턴 감지
    • 동적 인증 강도 조절
  3. 분산 시스템 통합
    • 서비스 메시와의 통합
    • 오픈 ID Connect와의 결합
    • 제로 트러스트 아키텍처 지원

마지막 조언

  1. 단순함 추구: 복잡한 인증 시스템은 취약점을 증가시킬 수 있습니다. 필요한 기능만 구현하세요.
  2. 지속적인 업데이트: 보안은 끊임없이 진화합니다. 정기적으로 시스템을 검토하고 새로운 보안 모범 사례를 적용하세요.
  3. 깊이 있는 방어: JWT만으로는 완벽한 보안을 구축할 수 없습니다. 여러 보안 계층을 구현하세요.
  4. 사용자 경험 균형: 보안을 강화하면서도 사용자 경험이 저하되지 않도록 균형을 맞추는 것이 중요합니다.
  5. 철저한 테스트: 인증 시스템은 철저히 테스트해야 합니다. 자동화된 테스트와 정기적인 보안 감사를 수행하세요.

이 시리즈가 여러분의 JWT 인증 시스템 구현에 도움이 되었기를 바랍니다. 안전하고 견고한, 그리고 사용자 친화적인 인증 시스템을 구축하여 애플리케이션의 보안과 사용자 경험을 모두 향상시키시길 바랍니다.

시리즈 요약

  • 1편: JWT의 기본 개념과 구조, 작동 원리
  • 2편: Node.js와 Express를 활용한 JWT 인증 구현
  • 3편: React와 Next.js에서의 클라이언트 측 JWT 관리
  • 4편: JWT 보안 강화 기법과 모범 사례

다음 주제로는 "JWT와 소셜 로그인 통합", "마이크로서비스 아키텍처에서의 JWT 활용", "JWT와 GraphQL API 인증" 등을 다루어 볼 예정입니다. 관심 있는 주제가 있으시면 댓글로 알려주세요!


참고 자료

  • OWASP JWT 보안 체크시트
  • RFC 7519 - JWT 표준
  • JWT.io
  • Auth0 JWT 핸드북
반응형
저작자표시 비영리 변경금지 (새창열림)

'Study > Authentication' 카테고리의 다른 글

JWT 인증 시리즈 3편: Frontend에서의 JWT 구현 (React/Next.js)  (0) 2025.04.27
JWT 인증 시리즈 2편: Node.js와 Express를 활용한 JWT 인증 구현  (0) 2025.04.27
JWT 인증 시리즈 1편: JWT 토큰 원리  (0) 2025.04.27
'Study/Authentication' 카테고리의 다른 글
  • JWT 인증 시리즈 3편: Frontend에서의 JWT 구현 (React/Next.js)
  • JWT 인증 시리즈 2편: Node.js와 Express를 활용한 JWT 인증 구현
  • JWT 인증 시리즈 1편: JWT 토큰 원리
모리군
모리군
    반응형
  • 모리군
    나의 일상 그리고 취미
    모리군
  • 전체
    오늘
    어제
    • 분류 전체보기 (23)
      • 독백 (0)
      • Study (11)
        • Authentication (4)
        • Supabase (2)
      • Javascript (3)
        • node.js (0)
        • react.js (0)
        • vue.js (0)
        • LeetCode (3)
      • AI (0)
      • PHP (0)
      • HTML, CSS (0)
      • 툴, 플러그인 (0)
      • 취미 (1)
        • 보드게임 (1)
      • 교통 (0)
        • 철도 (0)
        • 도로 (0)
      • 부동산 (2)
        • 서울 (0)
        • 경기 성남 (0)
        • 경기 수원 (0)
        • 경기 화성 (2)
        • 경기 남양주 (0)
        • 광주 (0)
      • 역사 (4)
        • 이 주의 역사 (1)
      • 영어기사 읽기 (1주일 1번) (0)
        • 스포츠 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    백엔드개발
    ChatGPT
    javascript
    LeetCode
    FastAPI
    PostgreSQL
    express
    임베딩
    백엔드
    OpenAI
    벡터데이터베이스
    Spring Boot
    윌리엄 딕슨
    초기 영화
    java
    Rag
    토큰관리
    광주민주화운동
    카카오T 대리
    슈파베이스
    REST API
    supabase
    대리운전
    웹 서비스
    JWT
    algorithm
    한국발명
    node.js
    프롬프트엔지니어링
    typescript
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
모리군
JWT 인증 시리즈 4편: JWT 보안 강화 및 모범 사례
상단으로

티스토리툴바