JWT 보안 모범 사례: 공격, 방어, 그리고 2026 체크리스트
JSON Web Token은 오늘날 대부분의 인증을 떠받치고 있다. 그런데 정작 토큰을 안전하게 지켜 주는 JWT 보안 모범 사례는 지켜져야 할 만큼 지켜지지 않는다. JWT는 OAuth 2.0, OpenID Connect, 마이크로서비스 내부의 서비스 간 호출에서 사실상 표준 자격 증명 형식이다. 동시에 해마다 CVE가 끊이지 않는 원천이기도 한데, 그 대부분은 충분히 피할 수 있었던 똑같은 실수로 거슬러 올라간다. 서명되지 않은 토큰을 받아들이고, 공격자가 고른 알고리즘을 믿고, 취약한 서명 비밀 키를 쓰고, 클레임 검증을 건너뛰는 것이다.
JWT가 안전하려면 네 가지가 동시에 성립해야 한다. 서명이 온전하고, 공격자가 알고리즘을 바꿔치기할 수 없고, 클레임을 실제로 확인하며, 손쉽게 탈취할 수 없는 곳에 토큰을 저장하는 것. 이 중 하나라도 무너지면 손에 쥐는 것은 견고해진 API가 아니라 인증 우회다. 아래에서는 가장 중요한 세 가지 공격을 먼저 짚고 방어로 넘어간다. 알고리즘을 고르고 고정하기, 키 관리, 클레임 검증, 토큰 저장 순이다. 끝에는 코드 리뷰에 바로 붙여 넣을 수 있는 체크리스트를 둔다.
JWT 서명은 실제로 무엇을 지켜 주는가 (그리고 무엇을 지켜 주지 않는가)
공격을 이해하려면 먼저 한 가지를 분명히 해 둬야 한다. JWT는 암호화된 것이 아니라 인코딩된 것이다. 서명된 토큰은 점으로 이어진 세 개의 Base64URL 조각, 즉 header.payload.signature로 이루어진다. 헤더와 페이로드는 JSON을 그대로 Base64URL로 인코딩한 것이라, 토큰을 가진 사람은 누구나 그 안의 모든 클레임을 읽을 수 있다. 아무 토큰이나 JWT 디코더에 붙여 넣어 보면, 키 없이도 헤더와 페이로드가 읽기 쉬운 JSON으로 그대로 펼쳐진다. 페이로드는 설계상 공개되도록 되어 있다.
그렇다면 보안은 어디에서 오는가? 서명, 오직 서명에서만 온다. 서명은 비밀 키(HMAC)나 개인 키(RSA, ECDSA)를 사용해 헤더와 페이로드 위에서 계산되는 암호학적 값이다. 공격자는 토큰을 마음껏 읽을 수 있지만, 서명 키 없이는 검증을 통과하는 다른 토큰을 만들어 낼 수 없다. 바로 이 한 가지 속성이 신뢰 모델의 전부다.
여기서 두 가지 결론이 나온다. 첫째, 페이로드에는 절대 비밀을 넣지 마라. 비밀번호, API 키, 개인정보(PII) 같은 것 말이다. 토큰을 가로챈 누구에게나 읽히기 때문이다. 둘째, 보안 태세 전체가 단 하나의 단계, 곧 서명을 올바르게 검증하는 데 달려 있다. 그리고 그 단계가 바로 공격자들이 노리는 지점이다. 토큰을 조각 단위로 읽는 과정을 더 깊이 들여다보고 싶다면 JWT 디코딩 방법을 참고하라.
JWT의 3대 핵심 공격 (그리고 각각을 막는 법)
대부분의 JWT 취약점은 한 가지 주제의 변주다. 서버가 공격자의 통제 아래 있는 무언가를 믿어 버린다는 것. 아래에 인증을 통째로 무너뜨리는 세 가지 공격을 작동 원리와 해결책과 함께 정리한다.
1. alg:none 공격 — 서명되지 않은 토큰 우회
JWS 명세에는 “서명되지 않음”을 뜻하는 alg 값 none이 들어 있다. alg:none 토큰은 서명 조각이 비어 있고, header.payload.처럼 끝에 점이 그대로 붙어 있다. 공격은 단순하다. 유효한 토큰을 하나 가져와 헤더의 alg를 none으로 바꾸고, 원하는 클레임(가령 "role": "admin")을 끼워 넣은 뒤 서명을 떼어 버린다. 초기 JWT 라이브러리들은 이걸 기본으로 받아들였고, 그래서 위조된 토큰이 검증을 유유히 통과했다. 키도 서명도 없이 완전한 사칭이 됐던 것이다.
이런 토큰이 어떻게 생겼는지는 JWT 디코더에서 “alg:none” 예제를 불러와 직접 볼 수 있다. 디코더는 해당 토큰이 서명되지 않았으며 인증 용도로 절대 받아들이면 안 된다는 빨간 경고를 띄운다. 직접 하나 재현해 보면 1분 만에 위협이 손에 잡힌다.
방어는 모든 검증 호출에서 알고리즘 허용 목록을 명시하는 것이다. 라이브러리 기본값이 무엇을 받아들일지 결정하게 내버려 두지 마라. 예전 기본값들은 허용적이었고, 명시적으로 적어 두는 비용이라곤 옵션 하나 추가하는 게 전부다.
// WRONG — the library may accept alg:none or any algorithm
jwt.verify(token, key);
// RIGHT — pin the exact algorithm you expect
jwt.verify(token, key, { algorithms: ['RS256'] });
none은 그 배열에 절대 나타나서는 안 된다. 알고리즘을 고정할 수 없는 라이브러리라면 교체하라.
2. 알고리즘 혼동 — RS256이 HS256으로 강등되다
실무에서 가장 위험한 JWT 취약점이다. 2015년부터 알려졌는데도 요즘 감사에서 여전히 나온다. 이 공격은 헤더의 alg 필드를 보고 어떻게 검증할지 결정하는 서버를 노린다. 그런데 그 alg 필드가 바로 토큰에서 공격자가 다시 쓸 수 있는 부분이다.
작동 원리는 이렇다. 당신의 서버는 RS256 토큰을 발급한다. RSA 개인 키로 서명하고, 짝이 되는 공개 키로 검증한다. 그 공개 키는 정의상 공개돼 있다. JWKS 엔드포인트에 놓여 있을 수도, 저장소에 들어 있을 수도 있다. 공격자는 그 공개 키를 가져와 토큰 헤더를 RS256에서 HS256으로 바꾸고, 공개 키 문자열을 HMAC 비밀 키로 삼아 HMAC-SHA256으로 위조된 페이로드에 서명한다. 이제 검증 측을 보자. 당신의 코드가 헤더에서 alg를 읽어 그에 맞춰 HMAC을 고른다면, 그 공개 키를 비밀 키로 삼아 토큰 위에서 HMAC-SHA256을 계산하게 된다. 서명이 일치한다. 위조된 토큰이 받아들여진다.
근본 원인은 두 가지가 맞물린 데 있다. 검증자가 공격자 통제 아래의 alg 헤더를 믿었다는 것, 그리고 RSA 공개 키를 공격자가 HMAC 키로 쓸 수 있었다는 것. 둘 다 그 자체로는 버그가 아니다. 공개 키는 공개되도록 되어 있고, alg 헤더는 토큰을 설명하도록 되어 있다. 취약점이 생기는 순간은 당신의 검증 로직이 그 헤더에 따라 어떤 키 유형과 알고리즘을 쓸지 고르게 둘 때다. 공격자가 적어 넣은 값이 서버가 실행하는 암호 경로를 좌우하게 되기 때문이다.
// WRONG — verification method follows the header's alg field
jwt.verify(token, publicKeyOrSecret);
// RIGHT — hard-code the expected algorithm; never let the header choose
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
비대칭 알고리즘을 명시적으로 고정하고(RS256 또는 ES256만), HMAC 검증을 RSA 검증과 완전히 분리된 코드 경로에 두며, 키 유형을 구분해 주는 잘 관리되는 라이브러리를 써라. JWT 디코더는 HS 계열 토큰이 나오면 공개 키 혼동 경고를 띄우는데, 이 공격이 워낙 흔해서다. 비대칭일 줄 알았던 토큰이 HS256으로 나타난다면, 그 경고가 곧 신호다.
3. 취약한 HMAC 비밀 키 — 무차별 대입과 사전 공격
HMAC(HS256/384/512)을 쓸 때는 토큰 보안 전체가 단 하나의 비밀 키가 가진 엔트로피에 달려 있다. 그 비밀 키가 짧거나, 사전에 나오는 단어이거나, secret이나 password123 같은 값이라면, 유효한 토큰 하나만 가로챈 공격자가 오프라인에서 그걸 해독해 낼 수 있다. hashcat 같은 도구는 토큰의 서명을 상대로 초당 수십억 개의 후보를 돌린다. 비밀 키가 한 번 뚫리면, 공격자는 원하는 어떤 토큰이든 찍어낼 수 있다. 영원히 유효한 admin 자격 증명을 손에 넣는 셈이다.
이 공격이 조용히 무서운 이유는 전 과정이 오프라인이라는 데 있다. 공격자는 당신의 로그인 엔드포인트를 두드리지 않으니, 걸려들 속도 제한도 없고 로그에 남을 흔적도 없다. 토큰 하나를 가로채 자기 하드웨어에서 비밀 키를 해독한 뒤, 당신이 걸어 둔 모든 검사를 통과하는 토큰에 서명할 수 있게 되고 나서야 비로소 돌아온다. 해결책에는 타협의 여지가 없다. 암호학적으로 안전한 소스에서 나온 최소 32바이트(256비트)의 무작위 값을 쓰고, 코드나 저장소가 아니라 비밀 관리자(secrets manager)에 보관하라.
// WRONG — guessable, low entropy, crackable in seconds
const secret = "password123";
// RIGHT — 256 bits from a CSPRNG, then load from KMS at runtime
const secret = require('crypto').randomBytes(32).toString('base64');
강력한 값이 당장 필요하다면 무작위 비밀번호 생성기로 HMAC 키에 쓸 만한 고엔트로피 문자열을 뽑을 수 있다. 차이를 직접 체감하고 싶다면 JWT 인코더에서 강력한 비밀 키로 테스트 토큰에 서명해 보라. 이 도구는 전적으로 브라우저 안에서 동작하므로 비밀 키가 기기를 벗어나지 않는다. 한편 검증이 신뢰 경계를 넘나들게 되면(여러 서비스, 외부 검증자), HS256을 아예 그만 쓰고 비대칭 알고리즘으로 옮겨라. 바로 아래에서 다룬다.
알맞은 알고리즘을 고르고 고정하기
혼동 공격의 승패가 알고리즘 선택에서 갈리니, 의도를 가지고 골라라. 실제로 쓰게 될 세 가지는 다음과 같다.
| 알고리즘 | 유형 | 서명 / 검증 키 | 언제 쓰나 |
|---|---|---|---|
| HS256 | 대칭 (HMAC) | 공유된 비밀 키 하나 | 단일 신뢰 경계, 같은 주체가 서명하고 검증 |
| RS256 | 비대칭 (RSA) | 개인 키로 서명 / 공개 키로 검증 | 서비스 간, 외부 검증, JWKS 교체 |
| ES256 | 비대칭 (ECDSA) | 개인 키로 서명 / 공개 키로 검증 | RS256과 동일하되 키가 더 작고 빠름 — 새 시스템에 권장 |
규칙은 짧다. 같은 주체가 하나의 신뢰 경계 안에서 서명하고 검증한다면 HS256이면 충분하고 빠르다. 서명자가 아닌 누군가가 검증해야 한다면(다른 서비스, 협력사, 공개 클라이언트), 비대칭 알고리즘을 쓰되 ES256을 우선하라. 같은 강도에서 키와 서명이 RSA보다 훨씬 작기 때문이다. JWT 인코더에서 HS256, RS256, ES256 샘플 토큰을 나란히 서명해 구조와 서명 길이를 비교해 볼 수 있다.
무엇을 고르든 핵심 방어는 똑같다. 검증 호출에서 명시적인 알고리즘 집합을 고정하고, 헤더의 alg 필드를 절대 믿지 마라. 이 허용 목록이 나머지 모든 것을 떠받치는 토대다.
키 관리와 교체
알고리즘은 그 뒤에 있는 키만큼만 안전하다. 그리고 키를 다루는 일이야말로 대부분의 가이드가 말을 아끼는 대목이다. HS256이라면 비밀 키는 최소 32바이트의 무작위 값이며 AWS Secrets Manager, HashiCorp Vault, Azure Key Vault 같은 비밀 관리자에 들어 있어야 한다. 비대칭 알고리즘이라면 개인 키는 HSM이나 KMS에 두고 애플리케이션 코드에는 결코 닿지 않게 한다. 공개 키는 보통 검증자가 가져가는 JWKS 엔드포인트로 게시한다.
키 교체는 비상 상황이 아니라 일상이어야 한다. 각 키에 JWT 헤더의 kid(키 ID)를 달아, 어떤 키가 주어진 토큰에 서명했는지 검증자가 알게 하라. 검증 측에는 유효한 키를 소수만 둔다. 현재 키와 바로 직전 키 정도면 된다. 그러면 교체 직전에 서명된 토큰도 유효 기간 동안 여전히 통과한다. 그 겹치는 구간 덕에 키 교체가 장애 없이 매끄럽게 넘어간다.
키에 관한 짧은 체크리스트는 다음과 같다.
- 서명 키는 최소 90일마다 교체하고, 침해가 의심되면 즉시 교체한다.
- 공개 키는 JWKS로 게시하고
kid로 버전을 매긴다. - 개인 키와 HMAC 비밀 키는 KMS나 HSM에 보관한다. git에도, 클라이언트 코드에도, 하드코딩으로도 절대 두지 않는다.
- 유출이 발생하면 키를 교체하고 미사용 갱신 토큰을 즉시 폐기한다.
건너뛸 수 없는 클레임 검증
서명 검사는 토큰이 진짜임을 증명한다. 하지만 그 토큰이 지금, 당신을 위한 것임까지 증명하지는 못한다. 그게 클레임 검증의 몫이고, 추가할 수 있는 방어 중 가장 값싸다. 모든 요청마다 확인해야 할 클레임은 다섯 가지다.
exp(만료) — 만료 시각이 이미 지난 토큰은 거부한다.nbf(유효 시작 이전) — 유효 구간이 열리기 전에 사용된 토큰은 거부한다.iat(발급 시각) — 비현실적으로 오래된 토큰은 선택적으로 거부한다.iss(발급자) — 토큰이 당신이 신뢰하는 발급자에게서 왔는지 확인한다.aud(대상) — 토큰이 당신의 서비스를 위해 찍혀 나왔는지 확인한다.aud검사를 빠뜨리는 것은 가장 흔한 조용한 구멍으로, 한 API를 위해 발급된 토큰이 다른 API에 재사용되도록 허용한다.
대부분의 라이브러리는 기대하는 값을 넘겨주면 이걸 대신 검증해 준다.
jwt.verify(token, key, {
algorithms: ['ES256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
clockTolerance: 5, // seconds, for distributed clock skew
});
작은 시간 오차는 허용하라. 보통 5초 정도면 된다. 그래야 서버 간의 사소한 시각 어긋남 때문에 멀쩡한 토큰이 거부되지 않는다. 다만 이걸 더 늘리고 싶은 충동은 눌러라. 허용치를 넉넉히 잡으면 만료된 토큰이 여전히 통하는 시간 창이 넓어지는데, 그 창이 바로 exp가 닫으려고 존재하는 창이다. 토큰의 exp와 iat 값을 눈으로 점검하려면 JWT 디코더에 넣고, Unix 타임스탬프 변환기로 타임스탬프를 변환해 보라.
토큰 수명과 JWT를 어디에 저장할 것인가
서버 측 검사는 이야기의 절반일 뿐이다. 클라이언트가 토큰을 어디에 두느냐가 그게 얼마나 쉽게 탈취되는지를 좌우하고, 저장이야말로 XSS와 세션 하이재킹이 만나는 지점이다. 실전에서 버티는 패턴은 이렇다. 수명이 짧은 액세스 토큰(15~60분)을, 폐기 가능한 별도의 장수 갱신 토큰과 짝지운다.
저장 위치 결정은 단 하나의 절충으로 귀결된다.
| 저장 위치 | XSS 노출 | CSRF 위험 | 권장 사항 |
|---|---|---|---|
| localStorage | 높음 — 페이지의 모든 자바스크립트가 읽을 수 있음 | 없음 | 세션 토큰에는 피하라 |
| HttpOnly + Secure + SameSite=Strict 쿠키 | 낮음 — 자바스크립트에 보이지 않음 | CSRF 보호 필요 | 세션에 권장 |
localStorage에 든 토큰은 페이지에서 실행되는 어떤 스크립트든 읽을 수 있다. 그래서 XSS 버그 하나면 세션 전체가 유출되고, 공격자는 토큰이 살아 있는 동안 자기 기기에서 그걸 재사용할 수 있다. 반면 HttpOnly 쿠키는 자바스크립트가 아예 읽지 못해, XSS의 피해를 공격자가 살아 있는 페이지 안에서 할 수 있는 일로 좁혀 준다. 나쁘긴 해도, 들고 달아날 수 있는 탈취된 자격 증명은 아니다. 쿠키 방식의 비용은 이제 CSRF 보호가 필요하다는 점인데, 쿠키가 모든 요청에 자동으로 따라붙어서다. SameSite=Strict에 CSRF 토큰을 더하면 해결된다. 액세스 토큰은 짧게 유지해 유출의 폭발 반경을 작게 하고, 갱신 토큰은 HttpOnly, Secure, SameSite 쿠키에 넣어라. 로그아웃하거나 침해가 의심되면 서버 측에서 갱신 토큰을 폐기하고 서명 키를 교체하라. XSS, CSRF, 보안 쿠키를 둘러싼 더 넓은 맥락은 웹 보안 모범 사례 가이드를 참고하라.
JWT 보안 체크리스트
JWT 기반 인증을 출시하기 전에 이 목록을 점검하라.
- 검증이 명시적인 알고리즘 허용 목록을 고정하고
alg:none을 거부한다. - 비대칭 검증이 기대하는 알고리즘을 하드코딩하며 헤더에서
alg를 절대 읽지 않는다(혼동 차단). - HS256 비밀 키는 최소 32바이트의 무작위 값이며 KMS에서 불러온다.
- 개인 키는 HSM/KMS에 두고, 공개 키는 JWKS를 통해 게시하며
kid로 버전을 매긴다. - 서명 키는 최소 90일마다 교체한다.
- 모든 요청이
exp,nbf,iat,iss,aud를 검증하며, 시간 허용치는 5초 이하다. - 액세스 토큰은 15~60분간 유효하고, 갱신 토큰은
HttpOnly쿠키에 둔다. - 페이로드에 비밀이 없다 — 페이로드는 암호화된 것이 아니라 인코딩된 것이다.
자주 묻는 질문
JWT는 기본적으로 안전한가요?
아니다. JWT 보안은 설정 나름이다. 알고리즘을 고정하고, alg:none을 거부하고, 고엔트로피 비밀 키나 키를 쓰며, 클레임을 검증해야 한다. 기본값이거나 허용적인 라이브러리 설정은 인증 우회를 자주 열어 준다.
가장 위험한 JWT 취약점은 무엇인가요?
알고리즘 혼동이다. RS256이 HS256으로 강등되고 공개 키가 HMAC 비밀 키로 쓰이는 공격이다. 2015년부터 알려졌는데도 여전히 감사에서 나오는데, 헤더의 alg로 검증 방식을 고르는 서버를 노리기 때문이다.
HS256을 써야 하나요, RS256을 써야 하나요?
같은 주체가 하나의 신뢰 경계 안에서 서명하고 검증할 때는 HS256을 쓰라. 다른 서비스나 제삼자가 검증해야 하거나 JWKS 교체가 필요할 때는 RS256이나 ES256을 쓰라. 새 시스템이라면 ES256을 우선하라. 같은 강도에서 키가 더 작고 빠르다.
JWT는 어디에 저장해야 하나요?
세션 토큰에는 HttpOnly, Secure, SameSite 쿠키를 우선하라. 자바스크립트가 읽을 수 없어 XSS 버그 하나로는 탈취되지 않기 때문이다. 세션 토큰에 localStorage는 피하라. XSS 하나면 세션 전체가 유출돼 재사용된다.
JWT 서명 키는 얼마나 자주 교체해야 하나요?
평상시에는 최소 90일마다 교체하고, 침해가 의심되면 즉시 교체하라. 키에 kid로 버전을 매기고, 검증자에 활성 키와 바로 직전 키를 함께 두어 교체 직전에 서명된 토큰도 여전히 통과하게 하라.
JWT는 변조될 수 있나요?
서명 키 없이는 불가능하다. 어떤 공격자도 검증을 통과하는 토큰을 위조할 수 없다. 다만 당신의 서버가 alg:none을 받아들이거나, 알고리즘 혼동에 취약하거나, 약한 비밀 키를 쓴다면 서명을 우회당할 수 있다. 이는 JWT 자체의 결함이 아니라 설정의 실패다.
어떤 클레임을 검증해야 하나요?
exp(만료), nbf(유효 시작 이전), iat(발급 시각), iss(발급자), aud(대상)를 검증하라. aud 검사를 빠뜨리는 것은 가장 흔한 조용한 취약점으로, 한 서비스를 위한 토큰이 다른 서비스에 재사용되도록 허용한다.
맺음말
JWT 보안은 복잡하지 않다. 다만 모든 계층이 버텨 줘야 한다. 서명이 당신의 유일한 보증이니, 올바르게 검증하라. 알고리즘 하나를 명시적으로 고정하고 헤더의 alg는 절대 믿지 마라. KMS에 보관하고 주기적으로 교체하는 강력한 키를 쓰라. 모든 요청마다 exp, nbf, iat, iss, aud를 검증하라. XSS가 닿을 수 없는 곳에 토큰을 저장하라.
실제로 적용해 보려면, 아무 토큰이나 JWT 디코더에 붙여 넣어 알고리즘과 클레임을 살피고 alg:none이나 HS 혼동 위험을 잡아내라. 그리고 JWT 인코더로 전적으로 브라우저 안에서 서명을 실험해 보라. 당신의 키는 기기를 절대 떠나지 않는다.