로그인하면 서버가 eyJhbGci...로 시작하는 긴 문자열을 준다. 알파벳과 숫자가 뒤섞여 있고 중간에 점이 두 개 찍혀 있다. 서명까지 붙어 있으니 내용도 암호화되어 있을 것 같다.
하지만 가운데 조각을 디코딩하면 토큰에 담긴 사용자 정보가 그대로 보인다. 그렇다면 서명은 무엇을 보호할까?
JWT의 세 조각이 맡는 역할을 살펴보고, 내용을 읽을 수 있어도 임의로 바꾼 토큰은 검증을 통과하지 못하는 이유를 알아본다. 토큰에 담지 말아야 할 정보와 발급 후 취소가 어려운 이유도 함께 다룬다.
1. 점 두 개로 나뉜 세 조각은 각각 뭔가
토큰에서 제일 먼저 보이는 건 점이다. xxxxx.yyyyy.zzzzz이렇게 생겼다. 점이 구분자라 세 조각이고, 조각마다 역할이 따로 있다.
세 조각은 각각 다른 일을 한다. 순서도 정해져 있다.
앞에서부터 Header, Payload, Signature 다. Header는 이 토큰을 어떤 알고리즘으로 서명했는지 적어 둔 곳이고, Payload는 실제 정보가 담기는 곳이고, Signature는 앞의 두 조각을 비밀키로 서명한 값이다.
세 조각 모두 base64url로 인코딩돼 있다. 그래서 알파벳과 숫자만 남고 사람 눈에는 암호처럼 보인다. 여기가 첫 번째로 오해가 생기는 자리인데, 뒤에서 다시 다룬다.
JWT는 JSON Web Token의 줄임말이다. 이름 그대로 JSON을 토큰으로 만든 것인데, 굳이 이런 걸 만든 이유가 있다. 로그인 방식 중에 세션이라는 게 있다. 서버가 사용자마다 번호표를 하나 발급해 두고, 그 번호가 누구인지는 서버 안의 장부에 적어 두는 방식이다. 요청이 올 때마다 서버는 번호표를 받아 장부를 뒤져야 누구인지 안다.
JWT는 반대로 간다. 누구인지를 토큰 안에 직접 적어서 준다. 그러면 서버는 요청을 받을 때마다 장부를 뒤지지 않아도 된다. 이걸 무상태(stateless)라고 한다. 서버가 사용자별로 들고 있는 것이 없다는 뜻이다.
굳이 하나만 비유하자면 세션은 손목밴드에 적힌 번호고, JWT는 홀로그램 도장이 찍힌 신분증이다. 번호는 접수대 장부를 봐야 누구인지 알지만, 신분증은 그 자리에서 이름과 권한을 읽을 수 있다. 도장이 진짜인지만 보면 된다.
2. 조각 안에는 무엇이 들어 있나
① Header
{ "alg": "HS256", "typ": "JWT" }
alg: 서명 알고리즘 (HS256=HMAC,RS256=RSA 등)typ: 토큰 타입 (JWT)
두 줄이 전부다. 이 토큰을 무슨 방식으로 서명했는지만 적혀 있다.
② Payload — 클레임(claims)
{
"sub": "42", // 주체(보통 userId)
"name": "홍길동",
"role": "admin",
"iat": 1722240000, // 발급 시각
"exp": 1722243600 // 만료 시각
}
여기 들어가는 항목 하나하나를 클레임(claim)이라고 한다. “이 사용자에 대해 이런 것이 사실이다”라고 토큰이 주장하는 내용이다.
이름을 아무렇게나 지어도 되는 건 아니고, 표준으로 약속된 것들이 있다. 등록된 클레임(registered claims) 이라고 한다.
| 클레임 | 뜻 |
|---|---|
iss |
issuer, 발급자 |
sub |
subject, 주체(사용자 식별) |
aud |
audience, 이 토큰을 받을 대상 |
exp |
expiration, 만료 시각(Unix time) |
nbf |
not before, 이 시각 전엔 무효 |
iat |
issued at, 발급 시각 |
jti |
JWT 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"으로 바꿔서 다시 보내면 그만 아닌가.
안 되는 이유는 서명 검증 방식에 있다. 서버가 토큰을 받으면 이렇게 한다.
⑤번이 핵심이다. 서버는 받은 토큰의 서명을 풀어 보는 게 아니다. 받은 header.payload를 자기 secret으로 다시 서명해 보고, 그 결과가 토큰에 붙어 있는 signature와 같은지 비교한다.
그래서 공격자가 payload의 "role":"user"를 "admin"으로 고치면, 서버가 다시 계산한 서명 값이 토큰에 붙어 있던 값과 달라진다. 서버는 그 자리에서 거부한다. 공격자가 서명까지 맞추려면 secret이 필요한데, secret은 서버만 갖고 있다.
이 두 가지를 같이 봐야 오해가 안 생긴다.
- 내용은 누구나 읽는다. base64를 되돌리는 데는 아무것도 필요 없다.
- 내용을 바꾸면 서버가 안다. 서명을 다시 만들려면 secret이 필요하고, 그건 공개되지 않는다.
서명은 내용을 감추는 장치가 아니라 바뀌었는지 알아내는 장치다. 이 한 줄을 붙잡고 있으면 나머지는 대체로 따라온다.
HS256과 RS256은 무엇이 다른가
alg에 들어가는 값이 여러 개인 건 취향 문제가 아니다. 크게 두 갈래다.
| HS256 (대칭) | RS256 (비대칭) | |
|---|---|---|
| 키 | 하나의 secret 으로 서명·검증 | 개인키로 서명, 공개키로 검증 |
| 검증자 | secret을 아는 쪽만(=서명자와 동일) | 공개키만 있으면 누구나 검증 |
| 적합 | 단일 서버 | MSA·여러 서비스가 검증(공개키 배포) |
표 2. HS256과 RS256의 키·검증자·적합한 구성 비교
서버가 하나뿐이면 HS256으로 충분하다. 서비스가 여러 개로 쪼개져 있고 각각이 토큰을 검증해야 한다면 RS256 쪽이 낫다. 검증만 하는 쪽에는 공개키만 주면 되니까, 서명을 만들 수 있는 키를 여기저기 뿌리지 않아도 된다.
5. 그럼 무엇을 넣으면 안 되나
3절의 결론이 그대로 규칙이 된다. 비밀번호, 주민등록번호, 카드번호 같은 것을 payload에 넣으면 안 된다. 토큰을 가진 사람은 그걸 그대로 읽는다. 서명이 붙어 있어도 마찬가지다.
payload에 들어가도 되는 건 “이 사람이 누구인지” 정도까지다. 사용자 번호, 권한 이름, 만료 시각. 노출돼도 그 자체로는 문제가 되지 않는 값들이다.
여기 말고도 알려진 문제가 몇 개 더 있다.
alg: none공격 —alg에none을 넣으면 서명이 없다는 뜻이 된다. 일부 라이브러리가 이걸 그대로 받아들이면 서명 없는 토큰이 통과한다. 라이브러리에서 허용할 알고리즘을 직접 명시해 막는다.- 알고리즘 혼동 — RS256으로 서명하기로 해 놓고 서버가
alg를 토큰이 시키는 대로 따르면, 공격자가alg를 HS256으로 바꾸고 공개된 공개키를 HMAC의 secret 자리에 넣어 서명을 만들 수 있다. 서버가 기대하는alg를 고정하면 된다. - 토큰을 어디에 두느냐 —
localStorage에 두면 자바스크립트로 읽히니 XSS가 나면 토큰이 그대로 넘어간다.HttpOnly쿠키에 두면 자바스크립트가 못 읽지만, 이번엔 브라우저가 요청마다 자동으로 붙여 보내서 CSRF를 따로 막아야 한다. 어느 쪽도 공짜가 아니다. 쿠키 속성 이야기는 로그인은 한 번뿐인데 서버는 계속 같은 사람으로 알아본다와 쿠키가 자동으로 붙는데 왜 토큰을 또 보내나에서 이어진다.
세 개 중 앞의 둘은 라이브러리 설정 한 줄로 끝나는데, 그 한 줄을 안 써 두면 서명 검증이라는 장치가 통째로 없는 것과 같아진다.
6. 도입 비용과 한계 — 한 번 준 토큰은 취소할 수 없다
1절에서 서버가 장부를 안 봐도 된다고 했다. 그게 JWT의 장점인데, 같은 이유로 치르는 것이 있다. 서버가 아무것도 기억하지 않으니, 이미 발급한 토큰을 되돌릴 방법이 없다.
세션 방식이었다면 서버 장부에서 그 줄을 지우면 끝난다. 다음 요청부터는 번호표를 대조할 곳이 없으니 바로 거절된다. JWT는 그 장부 자체가 없다. 서버는 토큰을 받을 때마다 서명이 맞는지와 exp가 지났는지만 본다. 서명은 여전히 맞고 만료 시각도 아직 안 왔으면, 서버 입장에서 그 토큰은 정상이다.
그래서 이런 일이 생긴다.
- 사용자가 로그아웃을 눌러도, 이미 나간 토큰은
exp까지 계속 쓸 수 있다. 브라우저에서 지웠을 뿐이지 토큰이 무효가 된 게 아니다. - 토큰이 탈취되면 만료될 때까지 손쓸 방법이 마땅치 않다.
- 관리자가 어떤 계정의 권한을 낮춰도, 이미
"role":"admin"이 박혀 나간 토큰은 만료 전까지 admin으로 통한다.
이게 세션과 JWT를 가르는 지점이다. 세션은 취소가 쉽고 확장이 번거롭다. 서버가 여러 대면 장부를 공유해야 하니까. JWT는 확장이 쉽고 취소가 어렵다. 어느 쪽이 더 낫다기보다 무엇을 포기할지의 선택에 가깝다.
7. 그래서 access와 refresh를 나눈다
취소가 안 된다는 문제를 정면으로 푸는 방법은 없다. 대신 토큰이 살아 있는 시간을 짧게 줄이는 방식이 실무 표준으로 자리 잡았다.
로그인할 때 토큰을 두 개 준다.
- Access token — 수명이 짧다. 보통 분 단위다. API 요청에 붙여 쓰는 건 이쪽이다. 탈취돼도 금방 만료된다.
- Refresh token — 수명이 길다. API 요청에는 안 쓰고, access가 만료됐을 때 새 access를 받아 오는 데만 쓴다.
핵심은 refresh token 쪽이다. 이건 서버에 저장해 둔다. 그러면 로그아웃할 때 서버에서 그 줄을 지울 수 있고, 지워진 refresh로는 새 access를 못 받는다. 취소가 안 되던 문제를 refresh에서만 되살린 셈이다. 쓸 때마다 새것으로 바꿔 주는 회전(rotation)까지 하면, 같은 refresh가 두 번 쓰이는 순간을 탈취 신호로 잡을 수도 있다.
토큰마다 jti를 넣어 두고 폐기 목록을 관리하는 방법도 있다. 다만 그 목록을 서버가 들고 있어야 하니, 무상태로 만들려던 처음 의도와는 어긋난다. 완전히 공짜인 방법은 없다.
그래서 서명은 무엇을 지키고 있었나
검증은 서버가 secret으로 서명을 다시 계산해 대조하는 방식이고, secret이 없으면 유효한 서명을 만들 수 없어서 내용을 바꾸면 거부된다.




답글 남기기