[TIL] JWT 인증, 개념부터 이해하기

dev_vming·2026년 9월 6일

TIL

목록 보기
8/8

📚 JWT 인증, 개념부터 이해하기


📕 JWT의 구조

3조각 구성

JWT는 점(.)으로 나뉜 세 조각의 문자열이다.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJpYXQiOjE3MDAwMDAwMDB9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
조각이름디코딩하면
1Header{"alg":"HS256"} — 서명 알고리즘
2Payload{"sub":"123","iat":1700000000,"exp":1700003600} — 클레임
3Signature1·2번을 비밀키로 서명한 값

각 조각은 Base64URL로 인코딩된다. 일반 Base64와 달리 + / = 대신 - _를 쓰고 패딩을 없앤다. URL·쿠키·HTTP 헤더에 그대로 넣어도 깨지지 않게 하기 위해서다.

인코딩 vs 암호화

Header와 Payload는 암호화되지 않는다. 누구나 디코드하면 내용을 읽는다.

atob("eyJzdWIiOiIxMjMiLCJpYXQiOjE3MDAwMDAwMDB9")
// → '{"sub":"123","iat":1700000000}'

JWT가 보장하는 것은 "비밀 유지"가 아니라 위조 불가(무결성) 다. 토큰 내용을 한 글자라도 바꾸면 서명이 어긋나 검증에서 걸린다. 그래서 토큰에는 공개돼도 괜찮은 식별자만 담고, 비밀번호 같은 민감 정보는 넣지 않는다.

서명 생성 방식

HS256 기준 서명은 이렇게 계산된다.

signature = HMAC-SHA256(
  base64url(header) + "." + base64url(payload),
  secret
)

검증할 때는 서버가 받은 header·payload로 같은 계산을 다시 해서 토큰에 붙어온 서명과 일치하는지 비교한다.


📗 클레임(Claims)

클레임의 종류

클레임은 Payload에 담기는 key-value다. RFC 7519가 세 가지로 나눈다.

종류설명예
Registered (등록됨)IANA 등록 표준 이름. 짧고 도구가 인식함sub, iat, exp
Public (공개)충돌 방지를 위해 네임스페이스를 붙인 공개 클레임https://example.com/role
Private (비공개)당사자끼리만 합의한 이름. 충돌 위험 있음role, orgId

자주 쓰는 등록 클레임

클레임풀네임의미
subsubject토큰의 주체(누구에 관한 것인가)를 나타내는 고유 식별자
iatissued at발급 시각 (Unix epoch 초)
expexpiration만료 시각. 이 시각 이후엔 무효
ississuer발급자. 여러 서비스가 토큰을 낼 때 구분용
audaudience이 토큰을 받아도 되는 대상
nbfnot before이 시각 전에는 무효
jtiJWT ID토큰 고유 ID. 블랙리스트·일회용 토큰 구현의 핵심

시각 클레임은 모두 초 단위 정수다 (밀리초 아님).

sub를 쓰는 이유

사용자 식별자를 userId 같은 임의 이름으로 넣을 수도 있다. 그래도 sub를 쓰는 이유는:

  • 표준이라 생태계가 안다. 검증 라이브러리, 디버거, API 게이트웨이, OAuth/OIDC가 전부 sub를 "주체"로 해석한다.
  • 나중에 외부 인증 시스템과 연동할 때 매핑 코드를 따로 짤 필요가 없다.

자동 검증 항목

서명만 맞다고 통과가 아니다. 검증 함수는 Payload를 보고 이런 것들을 막아준다.

  • exp가 지났으면 → 만료 오류
  • nbf가 아직 안 됐으면 → 오류
  • 옵션으로 넘긴 issuer·audience와 값이 안 맞으면 → 오류
  • 서버 간 시계 오차는 clockTolerance 옵션으로 몇 초 허용 가능

📘 문자열과 바이트

TextEncoder의 역할

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);

Buffer 대신 TextEncoder

과거 Node 전용 코드라면 Buffer.from(str, "utf-8")를 썼다. 하지만 Buffer는 Node 전용 객체라서 브라우저나 Edge 런타임엔 없다. TextEncoder는 표준이라 어디서나 동작한다.


📙 대칭키 서명 (HS256)

HMAC이란

HS256 = HMAC using SHA-256. HMAC은 "비밀키 + 메시지 → 고정 길이 태그"를 만드는 함수다.

tag = HMAC-SHA256(key, message)
  • 같은 key + 같은 message → 항상 같은 tag
  • key를 모르면 올바른 tag를 만들 수 없다
  • message가 1비트만 바뀌어도 tag가 완전히 달라진다

JWT의 세 번째 조각이 바로 이 tag다.

대칭의 의미

서명할 때 쓰는 키와 검증할 때 쓰는 키가 같다. 비밀 문자열 하나로 두 작업을 다 한다.

발급:  signature = HMAC(SECRET, header + "." + payload)
검증:  HMAC(SECRET, 받은데이터) === 받은signature ?

비대칭 서명과의 비교

대칭 (HS256)비대칭 (RS256, ES256)
키비밀키 1개개인키(서명) + 공개키(검증) 쌍
서명 가능한 주체비밀키를 가진 모두개인키를 가진 쪽만
검증 가능한 주체비밀키를 가진 모두공개키만 있으면 누구나
속도 / 크기빠르고 작음느리고 서명이 큼
키 유출 시비밀키 가진 모든 곳이 위조 위험원공개키가 유출돼도 위조 불가

선택 기준

  • 대칭(HS256) — 토큰을 발급하는 쪽과 검증하는 쪽이 같은 신뢰 경계 안에 있을 때. 하나의 백엔드가 로그인 시 토큰을 만들고 같은 백엔드가 검증하는 구조. 단순하고 빠르다.
  • 비대칭(RS256 등) — 제3자가 토큰을 검증해야 할 때. 인증 서버가 발급하고 여러 독립 서비스가 각자 검증하는 경우. 서비스에는 공개키만 배포하면 되므로 비밀키 유출 범위가 줄어든다.

알고리즘 혼동 공격

검증할 때는 허용할 알고리즘을 코드에서 못박아야 한다.

await jwtVerify(token, secret, { algorithms: ["HS256"] });

두 가지 공격 때문이다.

  1. alg: "none" 공격 — 헤더를 {"alg":"none"}, 서명을 빈 값으로 만든 토큰을 보낸다. 검증기가 이를 "서명 검사 안 함"으로 처리하면 아무 페이로드나 통과한다.
  2. RS256 → HS256 스왑 공격 — 서버가 비대칭을 쓰고 공개키가 공개돼 있을 때, 공격자가 헤더를 HS256으로 바꾸고 공개키 문자열을 HMAC 비밀키로 사용해 서명한다. 검증기가 헤더의 alg를 곧이곧대로 믿으면 통과시켜 버린다.

화이트리스트를 지정하면 검증기가 "나는 이 알고리즘만 받는다"고 선언하는 셈이라, 나머지는 즉시 거부된다.


📒 라이브러리와 실행 환경

Node.js 런타임과 Edge 런타임

서버 코드는 두 환경 중 하나에서 실행된다.

Node.js 런타임Edge 런타임
정체진짜 Node 프로세스V8 아이솔레이트만 있는 경량 환경
사용 가능 APIfs, 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를 쓰는 이유

정리하면 jose 선택은 "특정 런타임에 묶이지 않겠다"는 결정이다.

  • 표준 API만 쓰므로 실행 환경을 안 가린다
  • 배포 타깃(서버리스, 엣지, 컨테이너)이 바뀌어도 인증 코드는 그대로다
  • 미들웨어·API 핸들러·서버 컴포넌트가 하나의 검증 함수를 공유한다

📓 참고

이 글은 토큰 기반 인증 코드를 리뷰하며 정리한 내용이다.

RFC 7519 — JSON Web Token (JWT)
jose — GitHub
MDN — TextEncoder
MDN — Web Crypto API

profile
밍기적 개발하기🐛

0개의 댓글