~ / posts / developer
smith@lab:~/posts$ cat developer/jwt-structure-signature.md

JWT 구조와 서명 원리 — 헤더·페이로드·서명, HS256과 RS256 차이

로그인 API 응답에서 eyJhbGciOi... 로 시작하는 긴 문자열을 처음 받았을 때, 이게 암호화된 무언가인 줄 알았어요. 결론부터 말하면 JWT는 점(.) 두 개로 나뉜 헤더·페이로드·서명 세 부분이고, 헤더와 페이로드는 Base64URL로 인코딩만 된 JSON이라 누구나 읽을 수 있어요. 서명은 내용을 숨기는 게 아니라 "발급자가 만든 그대로인지"를 보장해요.

이 차이를 이해하면 "JWT에 뭘 넣어도 되나", "디코딩됐는데 왜 검증 실패지?" 같은 질문이 저절로 풀려요. 이 글에서는 Node.js 22 내장 crypto 만으로 HS256 토큰을 한 줄씩 직접 만들고 검증해 보고, HS256·RS256·ES256·EdDSA의 토큰 길이와 서명·검증 속도를 직접 재서 비교했어요.

핵심 요약

JWT는 Base64URL(헤더).Base64URL(페이로드).Base64URL(서명) 구조예요. 헤더와 페이로드는 암호화가 아니라 인코딩이에요.

서명은 헤더.페이로드 문자열을 키로 서명한 값이라, 페이로드를 한 글자만 바꿔도 검증이 실패해요.

HS256은 같은 비밀 키로 서명·검증하고, RS256·ES256은 개인 키로 서명하고 공개 키로 검증해요.

디코딩(읽기)과 검증(믿기)은 다른 일이에요. 서버는 반드시 검증한 뒤에 페이로드를 신뢰해야 해요.

JWT 구조: 점 두 개, 세 부분

JWT(JSON Web Token, RFC 7519)를 점으로 자르면 세 조각이 나와요.

JWT 문자열을 점으로 나눈 헤더, 페이로드, 서명 세 부분과 각각을 디코딩한 JSON 내용, 서명 계산 방식을 보여 주는 구조도
헤더와 페이로드는 읽히고, 서명은 위조를 막는다

실제 토큰을 터미널에서 바로 열어 볼 수 있어요.

TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiLquYDssqDsiJgiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3NTk2MjI0MDAsImV4cCI6MTc1OTYyMzMwMH0.WEIiav_ZUGFlO4CE6kHdulwpFP14LsweXYb7un5KuC8'
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' \
  | awk '{ while (length($0) % 4) $0 = $0 "="; print }' | base64 -d; echo
# {"sub":"user_123","name":"김철수","role":"admin","iat":1759622400,"exp":1759623300}

tr 로 Base64URL 문자를 표준으로 바꾸고, awk 로 빠진 패딩 = 를 채운 뒤 디코딩했더니 한글 이름까지 그대로 나왔어요. 패딩을 안 채우면 macOS base64 는 마지막 몇 글자를 조용히 잘라 버려요. 키도 비밀번호도 필요 없죠. JWT 디코더에 붙여 넣으면 같은 결과를 표로 보여 줘요. 모든 JWT가 eyJ 로 시작하는 건 {" 를 Base64로 바꾸면 eyJ 가 되기 때문이에요.

헤더와 페이로드: 무엇이 들어가나

헤더에는 서명 방식이 들어가요. alg 는 알고리즘(HS256, RS256, ES256 등), typ 은 보통 "JWT", 키가 여러 개라면 어떤 키로 서명했는지 알려 주는 kid 가 붙어요.

페이로드에는 클레임(claim)이라 부르는 정보가 들어가요. 이름이 정해진 등록 클레임이 있고, 나머지는 자유롭게 넣을 수 있어요.

클레임이름의미예시
issIssuer발급자https://auth.example.com
subSubject대상(보통 사용자 ID)user_123
audAudience이 토큰을 받을 서비스api.example.com
expExpiration만료 시각 (초 단위 유닉스 시간)1759623300
nbfNot Before이 시각 전엔 무효1759622400
iatIssued At발급 시각1759622400
jtiJWT ID토큰 고유 ID (재사용 방지·폐기용)9f1c...

exp, iat 은 초 단위예요. JS의 Date.now() 는 밀리초라서 그대로 넣으면 수만 년 뒤 만료되는 토큰이 돼요. 1759623300 이 언제인지는 타임스탬프 변환기에 넣으면 바로 보여요(2025-10-05 09:15 KST). 초와 밀리초 혼동 문제는 초와 밀리초 단위 버그 글에 따로 정리했어요.

서명: HS256 토큰을 직접 만들고 검증하기

서명은 Base64URL(헤더) + "." + Base64URL(페이로드) 문자열을 키로 서명한 값이에요. 라이브러리 없이 직접 만들어 보면 원리가 손에 잡혀요.

const crypto = require('node:crypto');
const b64url = obj => Buffer.from(JSON.stringify(obj)).toString('base64url');

function signHS256(payload, secret) {
  const input = `${b64url({ alg: 'HS256', typ: 'JWT' })}.${b64url(payload)}`;
  const sig = crypto.createHmac('sha256', secret).update(input).digest('base64url');
  return `${input}.${sig}`;
}

function verifyHS256(token, secret) {
  const [h, p, s] = token.split('.');
  if (!s) throw new Error('malformed');
  const header = JSON.parse(Buffer.from(h, 'base64url'));
  if (header.alg !== 'HS256') throw new Error('unexpected alg ' + header.alg); // alg는 서버가 정한다
  const expected = crypto.createHmac('sha256', secret).update(`${h}.${p}`).digest();
  const given = Buffer.from(s, 'base64url');
  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected))
    throw new Error('invalid signature');
  const payload = JSON.parse(Buffer.from(p, 'base64url'));
  if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('expired');
  return payload;
}

이제 공격자 입장에서 페이로드의 role 을 superadmin 으로 바꿔 끼워 봤어요.

const secret = 'a-very-long-random-secret-at-least-32-bytes!!';
const token = signHS256({ sub: 'user_123', role: 'admin', exp: 1759623300 }, secret);
const [h, , s] = token.split('.');
const forged = `${h}.${b64url({ sub: 'user_123', role: 'superadmin', exp: 1759623300 })}.${s}`;
verifyHS256(forged, secret);   // Error: invalid signature

페이로드는 누구나 바꿀 수 있지만, 바꾼 페이로드에 맞는 서명은 비밀 키 없이는 만들 수 없어요. 그래서 검증이 실패해요. 이게 서명이 보장하는 전부예요. 실무에서는 직접 구현하지 말고 jsonwebtoken, jose 같은 검증된 라이브러리를 쓰세요. 위 코드는 원리 확인용이에요.

HS256 vs RS256 vs ES256 차이

알고리즘은 크게 대칭 키(HMAC)와 비대칭 키(RSA, ECDSA, EdDSA)로 나뉘어요.

항목HS256RS256ES256EdDSA (Ed25519)
방식HMAC-SHA256RSA 서명 + SHA-256ECDSA P-256 + SHA-256Ed25519
키비밀 키 1개 공유개인 키 서명 / 공개 키 검증개인 키 / 공개 키개인 키 / 공개 키
서명 크기32바이트256바이트 (2048비트 키)64바이트64바이트
검증하는 쪽비밀 키를 가져야 함공개 키만 있으면 됨공개 키만 있으면 됨공개 키만 있으면 됨
잘 맞는 곳발급·검증이 같은 서버여러 서비스가 검증, 호환성토큰 크기가 중요할 때최신 스택, 작은 서명

같은 페이로드를 각 알고리즘으로 서명해 토큰 길이를 재 봤어요.

같은 페이로드를 알고리즘별로 서명한 JWT 전체 길이
HS256HS256 · 148자148자ES256ES256 · 191자191자EdDSAEdDSA · 214자214자RS256 2048비트RS256 2048비트 · 447자447자RS256 4096비트RS256 4096비트 · 788자788자

RS256은 서명만 342자 — 매 요청 헤더에 실린다는 걸 생각하면 무시할 수 없는 크기

단위: 자 · 자료: Node.js 22.18 crypto · jsonwebtoken 9.0.3 으로 직접 생성 (페이로드 sub·role·iat·exp 4개, 67자)
표로 보기
구분토큰 길이
HS256148
ES256191
EdDSA214
RS256 2048비트447
RS256 4096비트788

RS256 2048비트 토큰은 서명 부분만 342자로, 헤더와 페이로드를 합친 것보다 3배 길어요. 쿠키 하나는 4KB 제한이 있고 요청마다 실려 가니, 페이로드를 많이 넣는 서비스라면 ES256이나 EdDSA가 유리해요. 서명·검증 속도도 재 봤어요.

알고리즘별 초당 서명·검증 횟수 (1스레드)
서명검증
010203040RS256 2048비트 · 서명 1.2천 회RS256 2048비트 · 검증 34.5천 회RS256 2048비트ES256 · 서명 28.3천 회ES256 · 검증 10.8천 회ES256EdDSA · 서명 20.1천 회EdDSA · 검증 7.2천 회EdDSA

RSA는 서명이 느리고 검증이 빠르며, ECDSA·EdDSA는 그 반대

단위: 천 회 · 자료: Apple M1 · Node.js 22.18 crypto.sign/verify 를 0.7초씩 반복해 직접 측정 (다른 작업과 함께 돈 환경, 2회 중 높은 값). HS256 HMAC은 약 40만 회로 축척상 제외
표로 보기
구분서명검증
RS256 2048비트1.234.5
ES25628.310.8
EdDSA20.17.2

RSA는 서명이 느리지만(초당 약 1,200회) 검증은 빨라요. 토큰은 한 번 발급하고 여러 번 검증하니, 검증이 많은 API 게이트웨이에는 RS256이 의외로 잘 맞아요. HS256은 압도적으로 빠르지만 검증하는 모든 서비스가 비밀 키를 가져야 해서, 서비스가 늘어날수록 키 유출 위험이 커져요.

서명이 보장하는 것과 보장하지 않는 것

보장하는 것보장하지 않는 것
발급 후 내용이 바뀌지 않았다 (무결성)내용을 숨겨 준다 (기밀성)
키를 가진 쪽이 만들었다 (출처)토큰이 지금도 유효하다 (폐기 여부)
탈취된 토큰이 아니다

페이로드는 누구나 읽으니 주민번호, 전화번호, 비밀번호 같은 민감 정보는 절대 넣으면 안 돼요. 내용을 숨겨야 한다면 JWE(암호화된 JWT)라는 별도 규격이 있지만, 보통은 민감 정보를 넣지 않는 게 답이에요. 그리고 서명이 유효해도 그 토큰이 이미 로그아웃된 사용자의 것인지, 탈취된 것인지는 알 수 없어요. 만료 시간을 짧게 두는 이유가 여기 있어요. 이 설계는 액세스·리프레시 토큰 만료 설계에서 이어서 다뤘어요.

디코딩과 검증은 다른 일 — 자주 보는 에러 메시지

디코딩은 Base64URL을 풀어 읽는 것이고, 검증은 서명과 만료를 확인해 믿을지 결정하는 거예요. 프런트엔드에서 사용자 이름을 표시하려고 디코딩하는 건 괜찮지만, 서버는 반드시 검증한 뒤에 페이로드를 써야 해요. Node.js에서 가장 많이 쓰는 jsonwebtoken 9.x의 실제 에러 메시지를 정리했어요.

에러 메시지원인확인할 것
TokenExpiredError: jwt expiredexp 가 지남리프레시 흐름, 서버 시계
JsonWebTokenError: invalid signature서명 불일치키가 다름, 페이로드 변조, 환경별 키 혼동
JsonWebTokenError: jwt malformed점으로 나뉜 3부분이 아님Bearer 접두어 미제거, 따옴표 포함
JsonWebTokenError: jwt must be provided빈 값헤더 이름, 쿠키 전송 여부
JsonWebTokenError: invalid algorithm허용 목록에 없는 algalgorithms 옵션과 발급 alg 일치
JsonWebTokenError: jwt signature is required서명 없는 토큰 (alg: none)공격 시도일 수 있음
NotBeforeError: jwt not activenbf 이전서버 간 시계 차이
JsonWebTokenError: jwt audience invalid. expected: ...aud 불일치토큰 발급 대상 서비스

invalid signature 는 개발·운영 환경의 비밀 키가 다르거나, 키를 환경 변수로 넣으면서 줄바꿈·따옴표가 섞였을 때 가장 자주 봐요. jwt malformed 는 Authorization 헤더에서 Bearer 를 떼지 않고 통째로 넘긴 경우가 대부분이고요. 검증 단계에서 자주 하는 보안 실수는 JWT 보안 실수 체크리스트에 모아 뒀어요.

자주 묻는 질문

JWT는 암호화된 건가요?

일반적인 JWT(JWS)는 암호화가 아니라 서명이에요. 헤더와 페이로드는 Base64URL 인코딩이라 누구나 디코딩해서 읽을 수 있어요. 내용을 숨기려면 JWE라는 별도 규격을 써야 하고, 보통은 민감 정보를 넣지 않는 방식으로 해결해요.

HS256과 RS256 중 무엇을 써야 하나요?

토큰을 발급하는 서버와 검증하는 서버가 같다면 HS256이 단순하고 빨라요. 여러 서비스가 토큰을 검증하거나 외부에 공개 키를 배포해야 한다면 RS256이나 ES256처럼 공개 키로 검증하는 방식이 안전해요. 토큰 크기가 중요하면 ES256이나 EdDSA가 RS256보다 훨씬 짧아요.

JWT 디코딩은 되는데 invalid signature 에러가 나요.

디코딩은 키 없이도 되지만 검증은 발급할 때와 같은 키가 필요해요. 개발·운영 환경의 비밀 키가 다르거나, 환경 변수에 키를 넣을 때 따옴표·공백·줄바꿈이 섞였는지 확인하세요. RS256이라면 개인 키와 짝이 맞는 공개 키인지도 확인해야 해요.

JWT의 exp는 왜 숫자인가요?

exp 는 1970년 1월 1일 UTC부터 지난 초 수(유닉스 타임스탬프)예요. 밀리초가 아니라 초 단위라서 JS에서는 Math.floor(Date.now() / 1000) 로 비교해야 해요. 사람이 읽을 날짜로 보려면 타임스탬프 변환기에 넣으면 돼요.

JWT 페이로드에 무엇을 넣으면 안 되나요?

비밀번호, 주민등록번호, 전화번호, 주소 같은 개인정보나 비밀 값은 넣으면 안 돼요. 토큰은 브라우저, 로그, 프록시 어디서든 디코딩될 수 있어요. 사용자 ID, 권한, 만료 시각처럼 노출돼도 괜찮은 최소한의 정보만 넣으세요.

정리

JWT는 헤더·페이로드·서명 세 부분이고, 앞의 둘은 누구나 읽을 수 있는 Base64URL이에요. 서명은 내용을 숨기지 않지만 한 글자라도 바꾸면 검증이 실패하게 만들어요. 직접 재 보니 RS256 토큰은 HS256의 3배 길이였고, RSA는 검증이 빠르고 서명이 느렸어요. 디코딩과 검증을 구분하고, 페이로드에 민감 정보를 넣지 않는 것 — 이 두 가지가 JWT를 안전하게 쓰는 출발점이에요.

토큰 내용은 JWT 디코더로 확인하고, Base64URL이 표준 Base64와 어떻게 다른지는 URL-safe Base64와 패딩에서 볼 수 있어요.

#JWT 구조#JWT 서명 원리#HS256 RS256 차이#JWT 디코딩#invalid signature#jwt expired#JWT 클레임
← 이전 글JSON 파싱 에러 Unexpected token 원인 6가지와 해결법 총정리다음 글 →Base64는 암호화가 아니다 — 인코딩·암호화·해시 차이와 올바른 사용법