
JWT는 점(.)으로 나뉜 세 조각의 문자열이다.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJpYXQiOjE3MDAwMDAwMDB9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
| 조각 | 이름 | 디코딩하면 |
|---|---|---|
| 1 | Header | {"alg":"HS256"} — 서명 알고리즘 |
| 2 | Payload | {"sub":"123","iat":1700000000,"exp":1700003600} — 클레임 |
| 3 | Signature | 1·2번을 비밀키로 서명한 값 |
각 조각은 Base64URL로 인코딩된다. 일반 Base64와 달리 + / = 대신 - _를 쓰고 패딩을 없앤다. URL·쿠키·HTTP 헤더에 그대로 넣어도 깨지지 않게 하기 위해서다.
Header와 Payload는 암호화되지 않는다. 누구나 디코드하면 내용을 읽는다.
atob("eyJzdWIiOiIxMjMiLCJpYXQiOjE3MDAwMDAwMDB9")
// → '{"sub":"123","iat":1700000000}'
JWT가 보장하는 것은 "비밀 유지"가 아니라 위조 불가(무결성) 다. 토큰 내용을 한 글자라도 바꾸면 서명이 어긋나 검증에서 걸린다. 그래서 토큰에는 공개돼도 괜찮은 식별자만 담고, 비밀번호 같은 민감 정보는 넣지 않는다.
HS256 기준 서명은 이렇게 계산된다.
signature = HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret
)
검증할 때는 서버가 받은 header·payload로 같은 계산을 다시 해서 토큰에 붙어온 서명과 일치하는지 비교한다.
클레임은 Payload에 담기는 key-value다. RFC 7519가 세 가지로 나눈다.
| 종류 | 설명 | 예 |
|---|---|---|
| Registered (등록됨) | IANA 등록 표준 이름. 짧고 도구가 인식함 | sub, iat, exp |
| Public (공개) | 충돌 방지를 위해 네임스페이스를 붙인 공개 클레임 | https://example.com/role |
| Private (비공개) | 당사자끼리만 합의한 이름. 충돌 위험 있음 | role, orgId |
| 클레임 | 풀네임 | 의미 |
|---|---|---|
sub | subject | 토큰의 주체(누구에 관한 것인가)를 나타내는 고유 식별자 |
iat | issued at | 발급 시각 (Unix epoch 초) |
exp | expiration | 만료 시각. 이 시각 이후엔 무효 |
iss | issuer | 발급자. 여러 서비스가 토큰을 낼 때 구분용 |
aud | audience | 이 토큰을 받아도 되는 대상 |
nbf | not before | 이 시각 전에는 무효 |
jti | JWT ID | 토큰 고유 ID. 블랙리스트·일회용 토큰 구현의 핵심 |
시각 클레임은 모두 초 단위 정수다 (밀리초 아님).
사용자 식별자를 userId 같은 임의 이름으로 넣을 수도 있다. 그래도 sub를 쓰는 이유는:
sub를 "주체"로 해석한다.서명만 맞다고 통과가 아니다. 검증 함수는 Payload를 보고 이런 것들을 막아준다.
exp가 지났으면 → 만료 오류nbf가 아직 안 됐으면 → 오류issuer·audience와 값이 안 맞으면 → 오류clockTolerance 옵션으로 몇 초 허용 가능TextEncoder는 브라우저·Node·Deno·Edge 어디에나 있는 웹 표준 API다. 하는 일은 하나 — JS 문자열을 UTF-8 바이트 배열(Uint8Array)로 변환한다.
new TextEncoder().encode("A") // → Uint8Array [ 65 ]
new TextEncoder().encode("é") // → Uint8Array [ 195, 169 ] (2바이트)
new TextEncoder().encode("한") // → Uint8Array [ 237, 149, 156 ] (3바이트)
반대 방향은 TextDecoder가 맡는다.
HMAC, SHA-256 같은 알고리즘은 수학적으로 바이트 위에서 정의된다. "문자열"이라는 개념이 없다. 게다가 같은 문자열도 인코딩에 따라 바이트가 달라진다.
"한" → UTF-8이면 [237, 149, 156], UTF-16이면 [213, 92]라이브러리가 알아서 변환하면 "어떤 인코딩으로?"가 모호해지고, 서명하는 쪽과 검증하는 쪽이 다른 인코딩을 쓰면 서명이 안 맞는다. 그래서 암호 라이브러리는 "바이트는 직접 만들어 넘겨라" 라고 요구한다.
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
과거 Node 전용 코드라면 Buffer.from(str, "utf-8")를 썼다. 하지만 Buffer는 Node 전용 객체라서 브라우저나 Edge 런타임엔 없다. TextEncoder는 표준이라 어디서나 동작한다.
HS256 = HMAC using SHA-256. HMAC은 "비밀키 + 메시지 → 고정 길이 태그"를 만드는 함수다.
tag = HMAC-SHA256(key, message)
JWT의 세 번째 조각이 바로 이 tag다.
서명할 때 쓰는 키와 검증할 때 쓰는 키가 같다. 비밀 문자열 하나로 두 작업을 다 한다.
발급: signature = HMAC(SECRET, header + "." + payload)
검증: HMAC(SECRET, 받은데이터) === 받은signature ?
| 대칭 (HS256) | 비대칭 (RS256, ES256) | |
|---|---|---|
| 키 | 비밀키 1개 | 개인키(서명) + 공개키(검증) 쌍 |
| 서명 가능한 주체 | 비밀키를 가진 모두 | 개인키를 가진 쪽만 |
| 검증 가능한 주체 | 비밀키를 가진 모두 | 공개키만 있으면 누구나 |
| 속도 / 크기 | 빠르고 작음 | 느리고 서명이 큼 |
| 키 유출 시 | 비밀키 가진 모든 곳이 위조 위험원 | 공개키가 유출돼도 위조 불가 |
검증할 때는 허용할 알고리즘을 코드에서 못박아야 한다.
await jwtVerify(token, secret, { algorithms: ["HS256"] });
두 가지 공격 때문이다.
alg: "none" 공격 — 헤더를 {"alg":"none"}, 서명을 빈 값으로 만든 토큰을 보낸다. 검증기가 이를 "서명 검사 안 함"으로 처리하면 아무 페이로드나 통과한다.HS256으로 바꾸고 공개키 문자열을 HMAC 비밀키로 사용해 서명한다. 검증기가 헤더의 alg를 곧이곧대로 믿으면 통과시켜 버린다.화이트리스트를 지정하면 검증기가 "나는 이 알고리즘만 받는다"고 선언하는 셈이라, 나머지는 즉시 거부된다.
서버 코드는 두 환경 중 하나에서 실행된다.
| Node.js 런타임 | Edge 런타임 | |
|---|---|---|
| 정체 | 진짜 Node 프로세스 | V8 아이솔레이트만 있는 경량 환경 |
| 사용 가능 API | fs, crypto, Buffer, net 등 전부 | 웹 표준만 (fetch, TextEncoder, crypto.subtle) |
| 위치 / 지연시간 | 특정 리전 서버 | 전 세계 엣지 노드, 사용자와 가까움 |
| 콜드 스타트 | 상대적으로 느림 | 거의 없음 |
Edge 런타임에는 Node 전용 API가 대부분 없다. 대신 브라우저와 같은 Web Crypto API(crypto.subtle) 를 쓴다.
전통적인 JWT 라이브러리는 내부적으로 Node의 crypto 모듈에 의존한다. 그래서 Edge 런타임에서 import하면 빌드 에러나 런타임 크래시가 난다.
jose는 Web Crypto API만 사용한다. Node 런타임, Edge 런타임, 브라우저에서 같은 코드로 동작한다.
import { SignJWT, jwtVerify } from "jose";
const token = await new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setSubject("123")
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
const { payload } = await jwtVerify(token, secret, { algorithms: ["HS256"] });
많은 프레임워크에서 미들웨어(모든 요청 앞단에서 라우팅·인증을 처리하는 계층)는 Edge 런타임 전용이다. "쿠키의 JWT를 검증해 로그인 안 됐으면 리다이렉트" 같은 로직을 여기 두는 게 자연스러운데, Node 전용 라이브러리로는 못 짠다.
정리하면 jose 선택은 "특정 런타임에 묶이지 않겠다"는 결정이다.
이 글은 토큰 기반 인증 코드를 리뷰하며 정리한 내용이다.
RFC 7519 — JSON Web Token (JWT)
jose — GitHub
MDN — TextEncoder
MDN — Web Crypto API