← 목록으로 돌아가기

JWT 함정 모음: 알고리즘 혼동·클레임 검증 누락·토큰 탈취 대응까지 보안 설계 실전

Security

JWT claims validation pitfalls none algorithm security

JWT는 편리하지만, 그 편리함이 정확히 공격 지점이 된다

JWT(JSON Web Token)는 2010년대 중반 이후 웹 서비스 인증 표준으로 자리 잡았습니다. 서버가 상태를 저장하지 않아도 되고, 마이크로서비스 사이에서 검증이 간단하며, 다양한 언어와 플랫폼에서 라이브러리가 풍부합니다. 하지만 "편리하다"는 인식이 정확히 보안 취약점의 시작점이 됩니다.

우리 팀이 핀테크 스타트업에서 API 게이트웨이를 설계할 때, QA 단계에서 스테이징 환경 JWT 검증 코드를 점검하다가 알고리즘 파라미터 검증 로직이 누락된 것을 발견했습니다. 운 좋게 프로덕션 배포 전에 잡았지만, 해당 엔드포인트는 외부에 노출된 파트너 API였습니다. JWT를 "그냥 라이브러리에 넣으면 알아서 되는 것"으로 다뤘던 것이 문제였습니다.

RFC 7519는 JWT의 스펙을 정의하지만, 스펙을 충족하는 것과 안전하게 구현하는 것은 다른 문제입니다.


1. JWT 구조 재정리: Header·Payload·Signature

JWT는 점(.)으로 구분된 세 개의 Base64url 인코딩 문자열입니다. eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTc0OTEyMzQ1Nn0.SIGNATURE 형식입니다.

Header는 알고리즘과 토큰 타입을 담습니다. alg 필드가 핵심입니다.

Payload는 클레임(claim)의 집합입니다. RFC 7519 섹션 4는 등록 클레임(Registered Claims)을 정의합니다. iss(발급자), sub(주체), aud(수신자), exp(만료 시각), nbf(유효 시작 시각), iat(발급 시각), jti(JWT ID)가 표준 클레임입니다.

Signature는 헤더에 지정된 알고리즘으로 서명한 값입니다. 서명은 위변조를 탐지하지만, 페이로드를 암호화하지 않습니다. JWT 본문은 Base64url을 디코딩하면 누구나 읽을 수 있습니다.

흔한 오해는 "서명이 있으면 안전하다"입니다. 서명은 무결성(integrity)을 보장하지, 기밀성(confidentiality)을 보장하지 않습니다.

또 하나의 오해는 "라이브러리가 알아서 검증한다"는 것입니다. 대부분의 JWT 라이브러리는 서명 유효성만 확인하고, 클레임 검증은 호출자에게 맡깁니다.


2. alg:none 공격: 서명 검증을 생략한 라이브러리

alg:none은 JWT 스펙에 정의된 값입니다. 서명이 필요 없다는 의미입니다. 문제는 일부 라이브러리가 헤더에 "alg": "none"이 들어오면 실제로 서명 검증을 생략한다는 것입니다.

PortSwigger의 JWT 공격 연구에 따르면, alg:none 취약점은 여러 유명 라이브러리에서 발견됐으며 현재도 잘못 구성된 환경에서 악용 가능합니다.

방어는 간단합니다. 알고리즘을 항상 서버 측에서 명시적으로 지정하고, 허용 목록을 벗어난 알고리즘은 거절해야 합니다.

import jwt from 'jsonwebtoken';

const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;

function verifyAccessToken(token: string): jwt.JwtPayload {
  // algorithms 배열을 반드시 명시 - 'none'을 절대 포함하지 않는다
  const decoded = jwt.verify(token, PUBLIC_KEY, {
    algorithms: ['RS256'],
    issuer: 'https://auth.example.com',
    audience: 'api.example.com',
  });

  if (typeof decoded === 'string') {
    throw new Error('Invalid token payload');
  }

  return decoded;
}

jsonwebtoken에서 algorithms 옵션을 생략하면 라이브러리가 헤더의 alg 값을 그대로 신뢰합니다.


3. RS256 vs HS256 혼동 공격: 공개키를 비밀키로 오인

이 공격은 alg:none보다 더 교묘합니다. 시스템이 RS256(비대칭 서명)을 사용한다고 가정합니다. 서버는 비밀키(private key)로 서명하고, 공개키(public key)로 검증합니다.

공격자는 공개키를 얻은 뒤, 헤더의 algRS256에서 HS256(대칭 서명)으로 바꿉니다. 그리고 그 공개키를 HMAC 비밀키로 사용해 토큰에 서명합니다. 취약한 구현체가 헤더의 알고리즘을 읽어 "HS256이니까 공개키로 HMAC 검증을 하자"로 동작하면, 서명이 일치합니다.

알고리즘서명 키검증 키키 공개 여부
HS256공유 비밀키동일한 공유 비밀키비공개 (공유)
RS256RSA 비밀키 (private)RSA 공개키 (public)공개키만 공개 가능
ES256EC 비밀키 (private)EC 공개키 (public)공개키만 공개 가능

마이크로서비스 환경에서 HS256을 사용한다면 비밀키를 공유해야 합니다. 서비스가 많아질수록 키 유출 위험이 커집니다.


4. 만료(exp) 검증 누락 사례

exp 클레임은 토큰 만료 시각을 Unix timestamp로 표현합니다. 단순하지만 이 검증이 누락되거나 잘못 구현된 경우가 실무에서 반복됩니다.

가장 흔한 실수는 라이브러리의 옵션을 잘못 읽는 것입니다. 일부 라이브러리에서 ignoreExpiration: true 같은 옵션이 존재합니다. 개발·테스트 환경에서 편의를 위해 설정했다가 프로덕션 코드로 그대로 배포되는 경우입니다.

두 번째 실수는 클럭 스큐(clock skew)를 지나치게 크게 잡는 것입니다. NTP 동기화가 정상인 환경에서는 30~60초 이내로 충분합니다.

nbf(not before) 클레임도 확인해야 합니다. 아직 활성화되지 않은 토큰을 미리 사용하는 공격을 막습니다.


5. sub·aud·iss 클레임 검증 패턴

서명이 유효하고 만료되지 않았더라도, 토큰이 이 서비스·이 사용자·이 요청을 위한 것인지 확인해야 합니다.

iss (Issuer): 토큰 발급자입니다. 내부 인증 서버 URL이나 식별자를 명시합니다.

aud (Audience): 토큰을 수신해야 하는 대상입니다. OWASP JWT 보안 치트시트에서도 aud 검증 누락을 주요 취약점으로 지목합니다.

sub (Subject): 토큰 주체, 일반적으로 사용자 식별자입니다.

import { Request, Response, NextFunction } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';

const ALLOWED_ISSUERS = new Set(['https://auth.example.com']);
const SERVICE_AUDIENCE = 'api.example.com';

export async function jwtAuthMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Missing authorization header' });
    return;
  }

  const token = authHeader.slice(7);

  try {
    const payload = jwt.verify(token, PUBLIC_KEY, {
      algorithms: ['RS256'],
      issuer: Array.from(ALLOWED_ISSUERS),
      audience: SERVICE_AUDIENCE,
      clockTolerance: 30,
    }) as JwtPayload;

    // jti 기반 재사용 탐지
    const isRevoked = await tokenStore.isJtiRevoked(payload.jti!);
    if (isRevoked) {
      res.status(401).json({ error: 'Token has been revoked' });
      return;
    }

    next();
  } catch (err) {
    if (err instanceof jwt.TokenExpiredError) {
      res.status(401).json({ error: 'Token expired' });
    } else if (err instanceof jwt.JsonWebTokenError) {
      res.status(401).json({ error: 'Invalid token' });
    } else {
      next(err);
    }
  }
}

6. 토큰 크기와 페이로드 설계 원칙

JWT는 모든 요청의 Authorization 헤더에 들어갑니다. 페이로드가 커질수록 매 요청마다 네트워크 오버헤드가 늘어납니다. 더 심각한 문제는 민감한 데이터를 페이로드에 넣는 것입니다.

JWT 페이로드는 서명되어 있지만 암호화되어 있지 않습니다. 이메일, 전화번호, 주소, 결제 수단 ID처럼 노출되면 안 되는 정보를 JWT에 담으면 안 됩니다. 페이로드에는 검증에 필요한 최소한의 정보만 넣어야 합니다.

auth-session-cookie-refresh-token-security 포스트에서 다룬 것처럼, JWT에 많은 정보를 담으면 담을수록 토큰 탈취 시 노출되는 정보도 많아집니다.


7. 블랙리스트 없는 무효화: 짧은 만료 + Refresh Token

JWT의 stateless 특성은 장점이지만, 즉각적인 무효화가 어렵다는 단점을 만듭니다. 사용자가 로그아웃해도 access token은 만료 시각까지 유효합니다.

더 실용적인 접근은 access token의 수명을 짧게 유지하고, refresh token으로 갱신하는 구조입니다. access token을 5~15분으로 유지하면 탈취되더라도 피해 지속 시간이 제한됩니다. 강제 로그아웃이 필요할 때는 refresh token을 서버에서 폐기합니다.

보안 수준이 높은 작업(비밀번호 변경, 결제 승인)에서는 JWT 수명에 의존하지 않고 해당 요청 시점에 서버가 세션 유효성을 직접 확인하는 방식을 추가할 수 있습니다.


8. 서명 키 로테이션 운영: JWK Set과 kid 헤더

키를 영구적으로 사용하는 것은 위험합니다. JWK(JSON Web Key, RFC 7517)는 암호화 키를 JSON 형식으로 표현하는 스펙입니다. JWK Set은 여러 키의 집합입니다.

JWT 헤더에는 kid 필드를 포함합니다. 검증자는 kid로 JWK Set에서 올바른 키를 찾아 서명을 검증합니다.

const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');

const client = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
  cache: true,
  cacheMaxEntries: 10,
  cacheMaxAge: 10 * 60 * 1000,
  rateLimit: true,
  jwksRequestsPerMinute: 10,
});

function getSigningKey(header, callback) {
  if (!header.kid) {
    return callback(new Error('Missing kid in token header'));
  }
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

키 로테이션 절차:

  1. 새 키 쌍 생성, kid2로 JWK Set에 추가
  2. 검증자들이 JWK Set 캐시를 갱신하도록 대기
  3. 인증 서버를 kid2로 새 토큰 발급 전환
  4. kid1으로 발급된 기존 토큰의 최대 수명이 지날 때까지 JWK Set에 kid1 유지
  5. kid1 JWK Set에서 제거

프로덕션 환경에서의 시크릿 관리와 키 로테이션 자동화에 대해서는 production-secret-management-rotation-vault 포스트를 참고하시기 바랍니다.


9. 구현체별 함정: jsonwebtoken(Node), PyJWT, jjwt(Java)

jsonwebtoken (Node.js): verify 호출 시 algorithms 옵션을 생략하면 라이브러리가 헤더의 alg를 신뢰합니다. 2018년 이후 주요 버전에서 이 동작에 경고가 추가됐지만, 기존 코드에서는 여전히 생략된 경우가 있습니다.

PyJWT: 버전 1.x에서는 algorithms 파라미터 없이 decode를 호출하면 none 알고리즘을 허용했습니다. 버전 2.x부터 algorithms 파라미터가 필수화됐습니다.

jjwt (Java): setSigningKeyString 타입으로 호출하면 내부적으로 Base64 디코딩을 시도합니다. Keys.hmacShaKeyFor(bytes) 또는 Keys.secretKeyFor(SignatureAlgorithm.HS256) 처럼 타입 안전한 API를 사용합니다.

공통 원칙은 동일합니다. 알고리즘 화이트리스트, 클레임 검증 필수화, 보안 관련 옵션의 기본값 확인입니다.


10. 토큰 탈취 대응과 운영 체크리스트

탐지 신호: 동일한 refresh token이 다른 IP나 User-Agent에서 사용됨, 단시간에 비정상적인 refresh 요청 급증, 이미 rotation된 refresh token의 재사용 시도.

대응 절차: refresh token 재사용이 탐지되면 해당 세션 패밀리 전체를 즉시 폐기합니다. 사용자에게 재로그인을 요구하고, 보안 이벤트를 기록합니다.

로그와 모니터링: JWT 검증 실패 이유를 에러 유형별로 카운팅합니다. 알고리즘 불일치, 서명 실패, 만료, iss/aud 불일치를 구분합니다.


결론

JWT는 올바르게 구현하면 안전하고 효율적인 인증 수단입니다.

  • 알고리즘 화이트리스트를 서버 코드에 하드코딩합니다.
  • exp, iss, aud, nbf 클레임을 모두 검증합니다.
  • JWT 페이로드에는 검증에 필요한 최소한의 정보만 담습니다.
  • access token 수명을 짧게 유지하고, refresh token 로테이션으로 무효화 제어권을 갖습니다.
  • JWK Set과 kid 헤더를 활용해 서명 키 로테이션을 무중단으로 운영합니다.

JWT 보안은 라이브러리를 가져다 쓰는 것으로 완성되지 않습니다.