JWT 디코더

"alg=none" 공격 — 2026년에도 여전히 터지는 이유

JWT 명세(RFC 7519)는 토큰 헤더에 알고리즘 필드를 정의합니다. valid 값은 HS256, RS256, ES256, EdDSA — 그리고 "none"입니다. "none" 알고리즘은 토큰이 아예 서명되지 않았음을 뜻합니다. 원래 명분은 "JWS의 무결성이 다른 수단으로 이미 검증된 경우 유용"이었습니다 — 다른 레이어가 무결성을 보장하는 파이프라인용. 현실에서 그 각주는 현대 인증의 가장 인용되는 취약점 부류를 만들어냈습니다. 전형적 공격 시나리오 — 개발자가 JWT 라이브러리를 통합하고 verify 함수를 jwt.verify(token, secret) 식으로 호출합니다. 라이브러리는 헤더에 { "alg": "none" }이 들어 있으면 알고리즘을 보고 검증할 서명이 없다고 판단해 페이로드를 "verified"로 반환합니다. 공격자는 정상 토큰 하나를 받은 뒤 alg를 none으로 바꾸고 서명을 떼어, 누구로든 로그인할 수 있습니다. CVE 목록은 매년 새 항목이 추가됩니다 — CVE-2015-9235(jsonwebtoken), CVE-2018-1000531(inversoft), CVE-2019-7644(auth0/java-jwt), CVE-2022-23529(jsonwebtoken 재발생), 2024년까지 계속. 두 번째 변종은 "알고리즘 혼동" 공격입니다. RS256(비대칭)은 공개키로 서명을 검증합니다. HS256(대칭)은 공유 비밀로 검증합니다. 서버가 RS256 공개키를 저장해두고 verify 함수가 "key"라는 일반 파라미터를 받는다면, 공격자가 헤더를 alg: HS256으로 바꾸고 그 공개키를 HMAC 비밀처럼 써서 토큰에 서명합니다. 서버는 같은 키로 "검증"에 성공하고 위조 토큰을 받아들입니다. CVE-2016-10555, CVE-2019-20933 등 다수가 정확히 이 패턴입니다. 방어는 단순하지만 잊기 쉽습니다. 토큰의 alg 필드를 절대 신뢰하지 마세요. 검증자가 기대 알고리즘을 하드코딩(또는 엄격한 allowlist)하고 그 외(특히 "none")는 거부해야 합니다. 올바른 호출은 jwt.verify(token, key, { algorithms: ['RS256'] }) 같은 형태이고, 이 옵션이 실제 전달되고 있는지 코드에서 직접 확인해야 합니다. 2020년경부터 대부분 라이브러리가 안전한 allowlist를 기본값으로 두지만, 옵션 없이 검증자를 만드는 레거시 코드가 야생에 여전히 많습니다. alg 외에도 검증자는 exp(만료 안 됨), nbf(이미 valid), iss(issuer 일치), aud(audience가 자신), 그리고 신경 쓰는 nonce / jti를 모두 체크해야 합니다. JWT는 서명된 JSON일 뿐 — 암호 서명은 바이트가 변하지 않았다는 것만 증명하지 그 바이트가 당신의 애플리케이션에 올바른 의미라는 걸 증명하지 않습니다.
// VULNERABLE — accepts alg=none and algorithm confusion
const payload = jwt.verify(token, key);

// HARDENED — explicit allowlist + claim checks
const payload = jwt.verify(token, key, {
  algorithms: ['RS256'],     // never trust header alg
  issuer: 'https://api.example.com',
  audience: 'https://app.example.com',
  clockTolerance: 30,        // 30s skew, not minutes
});

// alg=none token an attacker would craft
header  = base64url('{"alg":"none","typ":"JWT"}')
payload = base64url('{"sub":"admin","exp":9999999999}')
signature = ''  // empty
token = `${header}.${payload}.`  // trailing dot, no sig

// Algorithm confusion: signing with the RS256 public key as HMAC key
const pub = fs.readFileSync('public.pem');
const forged = jwt.sign({ sub: 'admin' }, pub, { algorithm: 'HS256' });
// If the verifier doesn't pin algorithms, this passes.

제대로 된 Refresh Token — Rotation, Reuse Detection, Sliding Session

Access token은 짧고(5~15분) bearer이며 DB 조회 없이 검증 가능합니다. Refresh token은 길고(며칠~몇 주) 새 access token과 교환되며, access token과 달리 즉시 폐기 가능해야 합니다. JWT 기반 시스템 대부분이 조용히 실패하는 곳이 refresh flow 설계입니다. OAuth 2.1 draft와 IETF "OAuth 2.0 Security BCP"(RFC 9700, 2024) 모두 refresh token rotation을 의무화합니다 — 클라이언트가 refresh R로 새 access token을 받을 때마다 서버는 새 refresh R'를 발급하고 R을 무효화합니다. single-use 성질이 결정적입니다. rotation이 없다면 탈취된 refresh token은 무한 권한입니다. rotation이 있다면 R을 훔친 공격자는 한 번 쓰고, 정상 사용자 클라이언트가 다음에 refresh를 시도하는 순간 서버가 R의 두 번째 사용을 보고 — 무언가 잘못됐음을 알아채고 — 세션 패밀리 전체를 폐기할 수 있습니다. 이 "reuse detection"이 rotation의 진가입니다. 구현은 — 발급 refresh token마다 refresh-token family identifier(로그인 세션당 UUID)를 함께 저장. refresh를 받으면 옛 토큰을 used로 표시. 이미 used인 토큰으로 refresh 요청이 오면 (a) 정상 클라이언트가 네트워크 버그로 재시도 중이거나 (b) 누군가 토큰을 훔친 것입니다. 어느 쪽이든 안전한 동작은 같습니다 — 그 family의 모든 refresh token을 폐기, 신선한 로그인 강제, 보안 이벤트 기록. Auth0, Stripe, Supabase, 사실상 모든 주요 인증 공급자가 정확히 이 패턴입니다. Sliding expiration이 두 번째 핵심. "refresh token은 발급 후 30일 만료" 같은 hard deadline 대신, max idle window("마지막 사용 후 14일 안에 써야 함")와 absolute lifetime("어쨌든 첫 발급 후 90일 만료") 두 가지를 둡니다. 이로써 "앱을 안 쓰기 전까지 로그인 유지"라는 UX와 "수년간 살아있는 refresh token"이라는 보안 호러를 분리할 수 있습니다. 저장에 관한 실무 노트 — refresh token은 JWT여서는 안 됩니다. JWT는 stateless / self-contained라 즉시 폐기가 필요한 것의 모양과 정반대입니다. Refresh token은 서버 측에 opaque 랜덤 문자열(Base64URL 32+ 랜덤 바이트)로 DB에 keyed 저장 — family ID, user ID, device fingerprint, 발급 시각, 마지막 사용 시각, used 플래그를 함께. Access token은 JWT 유지 — 충분히 짧아서 폐기가 덜 중요하고, 즉시 폐기는 최대 access token 수명만큼만 기다리면 됨. access도 진짜 즉시 폐기가 필요하면 JWT를 버리고 opaque session ID + 매 요청 서버 lookup으로 가야 — Stripe, GitHub, 대부분의 컨슈머 앱이 실제로 그렇게 합니다.
// Refresh endpoint with rotation + reuse detection
async function refresh(refreshToken) {
  const row = await db.refreshTokens.findOne({ token: refreshToken });
  if (!row) throw new Unauthorized();

  if (row.used) {
    // REPLAY: revoke the entire family
    await db.refreshTokens.updateMany(
      { familyId: row.familyId },
      { revoked: true }
    );
    auditLog('refresh-token-replay', { userId: row.userId });
    throw new Unauthorized('session compromised');
  }

  // Mark current as used + idle window check
  if (Date.now() - row.lastUsedAt > 14 * DAY) throw new Unauthorized();
  if (Date.now() - row.firstIssuedAt > 90 * DAY) throw new Unauthorized();

  await db.refreshTokens.update({ id: row.id }, { used: true });

  // Issue rotated pair
  const newAccess = signJWT({ sub: row.userId }, { expiresIn: '15m' });
  const newRefresh = base64url(crypto.randomBytes(32));
  await db.refreshTokens.insert({
    token: newRefresh,
    familyId: row.familyId,        // same family, new member
    userId: row.userId,
    used: false,
    firstIssuedAt: row.firstIssuedAt, // PRESERVE absolute lifetime
    lastUsedAt: Date.now(),
  });
  return { accessToken: newAccess, refreshToken: newRefresh };
}

JWE vs JWS — 서명 말고 암호화가 필요한 순간

야생에서 보는 대부분의 "JWT"는 JWS — JSON Web Signature입니다. 페이로드는 Base64URL 인코딩될 뿐 암호화되지 않습니다 — 토큰을 가진 사람은 누구든 가운데 세그먼트를 base64 디코딩해 모든 claim을 읽을 수 있습니다. claim이 비민감("user 12345 로그인, X에 만료")이면 괜찮지만, 이메일·역할명·내부 user ID·요금제 등급 등 경쟁사나 규제 감사관이 읽으면 곤란한 정보가 들어 있다면 잘못된 선택입니다. JWE — JSON Web Encryption — 은 JWS와 같은 아이디어에 페이로드 암호화를 더한 것입니다. JWE 토큰은 3 세그먼트가 아니라 5 세그먼트입니다(header, encrypted key, IV, ciphertext, auth tag). 페이로드 읽기에 복호화 키가 필요합니다. 민감 데이터를 담은 토큰을 제3자(웹훅 수신자, 리다이렉트 URL, 완전히 신뢰하지 않는 모바일 클라이언트) 거쳐 보내야 한다면 JWE가 정답입니다. 결정 규칙은 단순합니다 — 이 토큰의 보유자가 claim을 읽어야 하는가? 네 → JWS. 보유자가 자신의 백엔드 또는 자신의 클라이언트이고, 서명이 무결성 보장. 아니오 — 토큰이 내용을 알면 안 되는 코드를 통과하고 끝에서만 우리 서비스가 복호화 → JWE. 자주 쓰는 결합 패턴은 "nested JWT" — 먼저 JWS로 서명한 뒤 그것을 JWE의 plaintext로 wrap. 수신자는 암호화(자신만 읽음)와 서명(발급자 검증)을 모두 얻습니다. 2026년 알고리즘 선택 — JWS는 신규 시스템에 EdDSA(Ed25519) 추천. 가장 빠르고, 시그니처가 가장 작고, curve confusion 함정이 없음. ES256(ECDSA P-256)이 광범위 호환의 fallback. RS256은 RSA를 요구하는 PKI 환경에 묶여 있을 때만 — 키가 크고 서명이 느립니다. JWE는 대칭 셋업이면 직접 AES-GCM + key wrap(alg=A256KW + enc=A256GCM), 비대칭이면 ECDH-ES + AES-GCM. RSA-OAEP는 필수가 아니면 피하세요 — 느리고 키 크기가 가혹합니다. 마지막 경고 — 암호화는 "토큰에 민감 데이터를 아예 넣지 말라"의 대용품이 아닙니다. 토큰은 로깅되고, 캐싱되고, 복붙되고, 스크린샷됩니다. "claim은 검증자가 필요한 최소"라는 원칙은 JWS만큼 JWE에도 똑같이 적용됩니다. 새어나가면 안 되는 claim의 가장 안전한 자리는 DB이고, JWT는 opaque pointer만 들고 다니는 것이 정석입니다.
// JWS — payload is readable, just signed
header.payload.signature
^^^^^^^.^^^^^^^.^^^^^^^^^
3 segments, base64url decode the middle to read claims.

// JWE — payload is encrypted (5 segments)
header.encryptedKey.iv.ciphertext.tag

// Node 'jose' library
import { SignJWT, jwtVerify, EncryptJWT, jwtDecrypt } from 'jose';

// JWS sign with EdDSA (recommended new default)
const jws = await new SignJWT({ sub: '123' })
  .setProtectedHeader({ alg: 'EdDSA' })
  .setIssuedAt()
  .setExpirationTime('15m')
  .sign(privateKey);

// JWE encrypt with AES-GCM
const jwe = await new EncryptJWT({ ssn: '123-45-6789' })
  .setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM' })
  .setExpirationTime('5m')
  .encrypt(symmetricKey);

// Nested: sign then encrypt
const inner = await new SignJWT({ sub: '123', email: '[email protected]' })
  .setProtectedHeader({ alg: 'EdDSA' }).sign(signingKey);
const outer = await new EncryptJWT({ jwt: inner })
  .setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM' }).encrypt(encKey);
최종 수정:

도구 소개

JWT(JSON Web Token) 디코더는 토큰을 헤더, 페이로드, 서명 세 부분으로 분리해 앞의 두 부분을 Base64URL JSON에서 디코딩합니다. sub, iat, exp 같은 표준 클레임과 커스텀 필드를 바로 확인할 수 있습니다. JWT는 OAuth/OIDC의 표준 베어러 토큰 형식이라 Authorization 헤더, 쿠키, API 응답 어디에서나 마주치게 됩니다.

사용 방법

  1. 점(.)으로 구분된 3개의 Base64URL 세그먼트 형태의 JWT를 입력합니다.
  2. 헤더(알고리즘, key id)와 페이로드(클레임) 패널을 확인합니다.
  3. exp가 지난 경우 만료 경고를 확인합니다.
  4. 스코프, 테넌트 ID, 사용자 정보 등 커스텀 클레임을 검토합니다.
  5. 서명 세그먼트로 JWKS에서 kid를 조회하여 검증합니다 (본 도구는 서명 검증을 수행하지 않습니다 — 라이브러리로 검증하세요).

주요 사용 사례

  • OAuth 플로우 디버깅 시 액세스 토큰 클레임 검사
  • "토큰 만료" 오류 분석을 위해 exp, iat 확인
  • 권한 오류 디버깅을 위해 scope, roles 검토
  • 인증 서버에서 추가한 커스텀 클레임이 실제로 클라이언트에 도달하는지 검증
  • OIDC id_token에서 노출된 사용자 속성 확인
  • 사양 비교를 위해 마스킹된 JWT 디코드를 버그 리포트에 첨부

자주 묻는 질문

Q. 디코딩과 검증은 같은가요?

A. 아닙니다. 디코딩은 Base64URL → JSON 변환일 뿐 누구나 할 수 있습니다. 검증은 서명 키(HS256 시크릿 또는 RS256/ES256 공개키)가 필요하며 토큰이 변조되지 않았음을 확인합니다.

Q. JWT가 그대로 읽히는데 보안 문제 아닌가요?

A. JWT는 기본적으로 암호화되지 않습니다. 비밀, 평문 비밀번호, 민감 PII를 JWT 페이로드에 넣지 마세요. 내용을 숨겨야 한다면 JWE(암호화 JWT)를 사용합니다.

Q. alg=none 경고는 무엇인가요?

A. alg=none JWT는 서명이 없습니다. 명시적으로 허용하지 않는 한 서버는 반드시 거부해야 합니다. 그렇지 않으면 공격자가 임의의 페이로드를 위조할 수 있습니다.

Q. 입력한 토큰이 어디로 전송되나요?

A. 전송되지 않습니다. 디코더는 브라우저 안 JavaScript에서만 동작하며 토큰이 페이지를 떠나지 않습니다.