aster®

JWT의 구조와 서명 검증 원리

2026-07 · 인증

점 두 개로 이어진 카드 세 조각. 앞의 두 조각은 속이 비쳐 보이는 투명한 판이고, 세 번째 조각만 불투명하게 채워져 둥근 인장이 눌려 있다.

로그인을 하면 서버가 eyJhbGci... 로 시작하는 긴 문자열 하나를 준다. 알파벳과 숫자가 뒤섞여 있고 중간에 점이 두 개 찍혀 있다. 서명이 붙은 문자열이지만 내용을 암호화한 것은 아니다.

확인해 보면 다르다. 가운데 조각만 떼어 브라우저 콘솔에 atob 를 걸면 사용자 번호도 이름도 권한도 그대로 찍힌다. 서명은 내용을 숨기는 대신 토큰이 변조됐는지 확인하는 데 쓰인다.

JWT의 구조와 서명 검증 과정을 살펴보고, 발급된 토큰을 취소할 때의 제약도 정리했다.

1. JWT의 세 가지 구성 요소

토큰에서 제일 먼저 보이는 건 점이다. xxxxx.yyyyy.zzzzz 이렇게 생겼다. 점이 구분자라 세 조각이고, 조각마다 역할이 따로 있다.

세 조각은 각각 다른 일을 한다. 순서도 정해져 있다.

flowchart LR
    JWT["eyJhbGci... . eyJzdWIi... . SflKxwRJ..."]
    JWT --> H["① Header<br/>어떤 알고리즘으로<br/>서명했나"]
    JWT --> P["② Payload<br/>담긴 정보(claims)<br/>sub·exp·role..."]
    JWT --> S["③ Signature<br/>위변조 방지 봉인"]

앞에서부터 Header, Payload, Signature 다. Header 는 이 토큰을 어떤 알고리즘으로 서명했는지 적어 둔 곳이고, Payload 는 실제 정보가 담기는 곳이고, Signature 는 앞의 두 조각을 비밀키로 서명한 값이다.

세 조각 모두 base64url 로 인코딩돼 있다. 그래서 알파벳과 숫자만 남고 사람 눈에는 암호처럼 보인다. 여기가 첫 번째로 오해가 생기는 자리인데, 뒤에서 다시 다룬다.

JWT 는 JSON Web Token 의 줄임말이다. 이름 그대로 JSON 을 토큰으로 만든 것인데, 굳이 이런 걸 만든 이유가 있다. 로그인 방식 중에 세션이라는 게 있다. 서버가 사용자마다 번호표를 하나 발급해 두고, 그 번호가 누구인지는 서버 안의 장부에 적어 두는 방식이다. 요청이 올 때마다 서버는 번호표를 받아 장부를 뒤져야 누구인지 안다.

JWT 는 반대로 간다. 누구인지를 토큰 안에 직접 적어서 준다. 그러면 서버는 요청을 받을 때마다 장부를 뒤지지 않아도 된다. 이걸 무상태(stateless)라고 한다. 서버가 사용자별로 들고 있는 것이 없다는 뜻이다.

굳이 하나만 비유하자면 세션은 손목밴드에 적힌 번호고, JWT 는 홀로그램 도장이 찍힌 신분증이다. 번호는 접수대 장부를 봐야 누구인지 알지만, 신분증은 그 자리에서 이름과 권한을 읽을 수 있다. 도장이 진짜인지만 보면 된다.

2. Header·Payload·Signature에 담기는 값

① Header

{ "alg": "HS256", "typ": "JWT" }

두 줄이 전부다. 이 토큰을 무슨 방식으로 서명했는지만 적혀 있다.

② Payload — 클레임(claims)

{
  "sub": "42", // 주체(보통 userId)
  "name": "홍길동",
  "role": "admin",
  "iat": 1722240000, // 발급 시각
  "exp": 1722243600 // 만료 시각
}

여기 들어가는 항목 하나하나를 클레임(claim)이라고 한다. "이 사용자에 대해 이런 것이 사실이다" 라고 토큰이 주장하는 내용이다.

이름을 아무렇게나 지어도 되는 건 아니고, 표준으로 약속된 것들이 있다. 등록된 클레임(registered claims) 이라고 한다.

클레임뜻
ississuer, 발급자
subsubject, 주체(사용자 식별)
audaudience, 이 토큰을 받을 대상
expexpiration, 만료 시각(Unix time)
nbfnot before, 이 시각 전엔 무효
iatissued at, 발급 시각
jtiJWT ID, 토큰 고유 식별자(블랙리스트용)

표 1. 표준으로 약속된 등록된 클레임

name 이나 role 처럼 여기 없는 것을 넣어도 된다. 그건 커스텀 클레임이라고 부르고, 서비스가 필요한 대로 자유롭게 정한다.

③ Signature

Signature = HMACSHA256(
  base64url(header) + "." + base64url(payload),
  secret        ← 서버만 아는 비밀키
)

앞의 두 조각을 이어 붙인 문자열을, 서버만 아는 비밀키로 서명한 값이다. 이 조각 하나가 위변조 방지의 전부다.

3. 토큰 디코딩 예시

말로만 읽으면 잘 안 와닿으니 실제 값으로 확인해 보자. 아래는 여기저기서 예시로 자주 쓰이는 토큰이다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJKb2huIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└────────── Header ──────────┘ └──────────── Payload ────────────┘ └──────── Signature ────────┘

앞 조각 eyJhbGci... 를 base64 디코딩하면 {"alg":"HS256","typ":"JWT"} 가 나온다. 가운데 eyJzdWIi... 를 디코딩하면 {"sub":"42","name":"John","iat":1516239022} 가 나온다. 뒤의 SflKxwRJ... 는 서명이라 디코딩해도 사람이 읽을 만한 값이 아니다.

브라우저 콘솔을 열고 직접 해볼 수 있다.

atob("eyJzdWIiOiI0MiIsIm5hbWUiOiJKb2huIiwiaWF0IjoxNTE2MjM5MDIyfQ");

atob 는 브라우저에 원래 있는 함수다. 비밀키도 필요 없고 라이브러리도 필요 없다. 그냥 붙여 넣고 엔터를 치면 페이로드가 그대로 읽힌다.

이게 이 글에서 제일 중요한 대목이다. base64 는 암호화가 아니다. 사람이 읽기 어려운 문자로 바꿔 놓은 것일 뿐이고, 되돌리는 데 아무 열쇠도 필요 없다. 되돌리는 함수가 브라우저에 기본으로 들어 있을 정도다.

여기서 이슈가 생긴다. 서명이 붙어 있으니 내용도 감춰진다고 보고 payload 에 아무거나 담는 것이다. 실제로는 토큰을 손에 넣은 사람이면 누구나 그 안을 읽는다. 브라우저 개발자 도구에 토큰이 찍혀 있으면 그걸로 끝이다.

4. 서명으로 토큰 변조 확인하기

그럼 다음 질문이 자연스럽게 나온다. 내용이 다 보인다면 "role":"user" 를 "role":"admin" 으로 바꿔서 다시 보내면 그만 아닌가.

안 되는 이유는 서명 검증 방식에 있다. 서버가 토큰을 받으면 이렇게 한다.

sequenceDiagram
    actor U as 브라우저
    participant A as 인증 서버
    participant R as 리소스 서버(API)

    U->>A: ① 로그인(id, pw)
    A->>A: ② 자격 검증 → JWT 생성 → secret으로 서명
    A-->>U: ③ access token(JWT) 발급
    Note over U: 토큰 보관 (HttpOnly 쿠키 권장)

    U->>R: ④ GET /api  (Authorization: Bearer eyJ...)
    R->>R: ⑤ 서명 재계산·대조 + exp 확인
    Note over R: 저장소 조회 없음! 봉인만 확인 → 무상태
    R-->>U: ⑥ 200 OK

⑤번이 핵심이다. 서버는 받은 토큰의 서명을 풀어 보는 게 아니다. 받은 header.payload 를 자기 secret 으로 다시 서명해 보고, 그 결과가 토큰에 붙어 있는 signature 와 같은지 비교한다.

그래서 공격자가 payload 의 "role":"user" 를 "admin" 으로 고치면, 서버가 다시 계산한 서명 값이 토큰에 붙어 있던 값과 달라진다. 서버는 그 자리에서 거부한다. 공격자가 서명까지 맞추려면 secret 이 필요한데, secret 은 서버만 갖고 있다.

이 두 가지를 같이 봐야 오해가 안 생긴다.

서명은 내용을 감추는 장치가 아니라 바뀌었는지 알아내는 장치다. 이 한 줄을 붙잡고 있으면 나머지는 대체로 따라온다.

HS256 과 RS256 은 무엇이 다른가

alg 에 들어가는 값이 여러 개인 건 취향 문제가 아니다. 크게 두 갈래다.

HS256 (대칭)RS256 (비대칭)
키하나의 secret 으로 서명·검증개인키로 서명, 공개키로 검증
검증자secret을 아는 쪽만(=서명자와 동일)공개키만 있으면 누구나 검증
적합단일 서버MSA·여러 서비스가 검증(공개키 배포)

표 2. HS256 과 RS256 의 키·검증자·적합한 구성 비교

서버가 하나뿐이면 HS256 으로 충분하다. 서비스가 여러 개로 쪼개져 있고 각각이 토큰을 검증해야 한다면 RS256 쪽이 낫다. 검증만 하는 쪽에는 공개키만 주면 되니까, 서명을 만들 수 있는 키를 여기저기 뿌리지 않아도 된다.

5. Payload에 넣지 말아야 할 정보

3절의 결론이 그대로 규칙이 된다. 비밀번호, 주민등록번호, 카드번호 같은 것을 payload 에 넣으면 안 된다. 토큰을 가진 사람은 그걸 그대로 읽는다. 서명이 붙어 있어도 마찬가지다.

payload 에 들어가도 되는 건 "이 사람이 누구인지" 정도까지다. 사용자 번호, 권한 이름, 만료 시각. 노출돼도 그 자체로는 문제가 되지 않는 값들이다.

여기 말고도 알려진 문제가 몇 개 더 있다.

세 개 중 앞의 둘은 라이브러리 설정 한 줄로 끝나는데, 그 한 줄을 안 써 두면 서명 검증이라는 장치가 통째로 없는 것과 같아진다.

6. 발급된 토큰의 취소 문제

1절에서 서버가 장부를 안 봐도 된다고 했다. 그게 JWT 의 장점인데, 같은 이유로 치르는 것이 있다. 서버가 아무것도 기억하지 않으니, 이미 발급한 토큰을 되돌릴 방법이 없다.

세션 방식이었다면 서버 장부에서 그 줄을 지우면 끝난다. 다음 요청부터는 번호표를 대조할 곳이 없으니 바로 거절된다. JWT 는 그 장부 자체가 없다. 서버는 토큰을 받을 때마다 서명이 맞는지와 exp 가 지났는지만 본다. 서명은 여전히 맞고 만료 시각도 아직 안 왔으면, 서버 입장에서 그 토큰은 정상이다.

그래서 이런 일이 생긴다.

이게 세션과 JWT 를 가르는 지점이다. 세션은 취소가 쉽고 확장이 번거롭다. 서버가 여러 대면 장부를 공유해야 하니까. JWT 는 확장이 쉽고 취소가 어렵다. 어느 쪽이 더 낫다기보다 무엇을 포기할지의 선택에 가깝다.

7. Access Token과 Refresh Token 분리

취소가 안 된다는 문제를 정면으로 푸는 방법은 없다. 대신 토큰이 살아 있는 시간을 짧게 줄이는 방식이 실무 표준으로 자리 잡았다.

sequenceDiagram
    actor U as 브라우저
    participant A as 인증 서버
    U->>A: 로그인
    A-->>U: access(짧음·15분) + refresh(김·2주)
    Note over U: access로 API 호출
    U->>A: access 만료됨 → refresh 제출
    A->>A: refresh 유효성 확인(서버 저장·회전)
    A-->>U: 새 access 발급

로그인할 때 토큰을 두 개 준다.

이 구성에서는 refresh token을 서버에 저장해 둔다. 그러면 로그아웃할 때 서버에서 그 줄을 지울 수 있고, 지워진 refresh 로는 새 access 를 못 받는다. 취소가 안 되던 문제를 refresh 에서만 되살린 셈이다. 쓸 때마다 새것으로 바꿔 주는 회전(rotation)까지 하면, 같은 refresh 가 두 번 쓰이는 순간을 탈취 신호로 잡을 수도 있다.

토큰마다 jti 를 넣어 두고 폐기 목록을 관리하는 방법도 있다. 다만 그 목록을 서버가 들고 있어야 하니, 무상태로 만들려던 처음 의도와는 어긋난다. 폐기 목록을 관리하려면 서버 저장소와 조회가 추가로 필요하다.

서명이 보장하는 범위

검증은 서버가 secret 으로 서명을 다시 계산해 대조하는 방식이고, secret 이 없으면 유효한 서명을 만들 수 없어서 내용을 바꾸면 거부된다.

관련 글