이전 편에서 JWT의 세 번째 구성 요소인 Signature를 간략히 소개했다. 이번 편에서는 서명이 왜 필요하고, 어떤 원리로 위변조를 감지하며, 대칭키와 비대칭키 방식이 각각 어떻게 다른지를 깊이 파고든다.
서명이 왜 필요한가
이전 편에서 봤듯이, JWT의 Header와 Payload는 Base64URL로 인코딩되어 있을 뿐 암호화되어 있지 않다. 누구나 디코딩해서 내용을 볼 수 있고, 수정해서 다시 인코딩하는 것도 가능하다.
서명이 없다면 이런 공격이 가능하다. 일반 사용자가 받은 토큰의 Payload에서 "role": "USER"를 "role": "ADMIN"으로 바꾸고, 다시 Base64URL로 인코딩해서 서버에 보내는 것이다. 서버는 이걸 구분할 방법이 없다.
Signature는 이 문제를 해결하기 위해 존재한다. 토큰이 발급된 이후 단 한 글자라도 변경되면 서명이 맞지 않게 되므로, 서버는 변조를 즉시 감지할 수 있다.
위변조 감지 원리
공격자가 Payload를 변조하는 시나리오를 따라가 보자.
role: USER Note over Attacker: 2. Payload 변조
role: ADMIN Note over Attacker: 3. 서명은 그대로 두고 전송
(Secret Key를 모르니
새 서명을 못 만듦) Attacker->>Server: 변조된 JWT 전송 Server->>Verify: Header + 변조된 Payload로
서명을 재계산 Note over Verify: 재계산한 서명:
x7Kj2mN9pQ...
토큰에 있는 서명:
SflKxwRJSM...
→ 불일치! Verify-->>Server: 서명 불일치 감지 Server-->>Attacker: 401 Unauthorized
서버가 토큰을 검증하는 핵심 로직은 이렇다.
- 토큰에서 Header와 Payload를 꺼낸다
- Header와 Payload를 가지고, 서버가 보관하고 있는 Secret Key로 서명을 재계산한다
- 재계산한 서명과 토큰에 포함된 서명을 비교한다
- 일치하면 변조되지 않은 것, 불일치하면 변조된 것
공격자가 Payload를 바꾸면 2번에서 재계산되는 서명이 달라진다. 그런데 공격자는 Secret Key를 모르니까, 변조된 Payload에 맞는 올바른 서명을 만들 수 없다. 그래서 3번에서 항상 불일치가 발생한다.
서명은 "이 내용(Header + Payload)이 이 키(Secret Key)로 발급되었다"는 증거다. 내용이 바뀌면 증거도 달라져야 하는데, 그 증거를 만들려면 키가 필요하다. 키를 모르는 사람은 유효한 증거를 만들 수 없다는 것이 서명의 핵심 원리다.
대칭키 서명 — HMAC (HS256)
서명 알고리즘은 크게 두 갈래로 나뉜다. 먼저 대칭키 방식인 HMAC부터 살펴보자.
HS256(HMAC-SHA256)은 하나의 키로 서명하고, 같은 키로 검증하는 방식이다. "대칭키"라는 이름 그대로, 서명과 검증에 동일한 키를 사용한다.
→ 검증 성공
코드로 보면 이렇다.
// 토큰 생성
public String generateToken(User user) {
return Jwts.builder()
.setSubject(user.getEmail())
.claim("user_id", user.getId())
.claim("role", user.getRole())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 3600000))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)— SECRET_KEY 하나로 서명을 만든다
// 토큰 검증
public Claims validateToken(String token) {
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
}
.setSigningKey(SECRET_KEY)— 생성할 때와 동일한 키로 검증한다
구현이 단순하고 서명/검증 속도가 빠르다는 장점이 있다. 하지만 치명적인 한계가 하나 있다.
HMAC의 한계
서명하는 쪽과 검증하는 쪽이 같은 키를 가져야 한다. 서버가 한 대라면 문제없지만, 여러 서비스가 토큰을 검증해야 하는 마이크로서비스 환경에서는 모든 서비스에 Secret Key를 배포해야 한다.
Secret Key를 가진 서비스가 많아질수록, 키가 노출될 가능성도 높아진다. 그리고 Secret Key를 가진 서비스는 토큰을 검증할 수 있을 뿐 아니라 위조도 할 수 있다. 결제 서비스가 해킹당하면, 그 키로 가짜 관리자 토큰을 만들어서 다른 서비스를 공격할 수 있다는 뜻이다.
비대칭키 서명 — RSA (RS256)
이 문제를 해결하는 게 비대칭키 방식이다. RS256(RSA-SHA256)은 개인키로 서명하고, 공개키로 검증한다. 서명하는 키와 검증하는 키가 다르다.
(서명용) participant Order as 주문 서비스 participant PUB as 공개키
(검증용) Note over Auth, PK: 인증 서비스만 개인키 보유 Auth->>PK: header.payload + 개인키 Note over PK: RSA-SHA256 연산 PK-->>Auth: 서명 생성 Auth-->>Order: JWT 전달 Note over Order, PUB: API 서비스들은 공개키만 보유 Order->>PUB: header.payload + 공개키 Note over PUB: 서명 검증 PUB-->>Order: 검증 성공
핵심 차이는 이렇다. 인증 서비스만 개인키를 갖고 있고, 나머지 서비스들은 공개키만 갖고 있다.
공개키로는 서명을 검증만 할 수 있고, 새로운 서명을 만들 수는 없다. 그래서 API 서버가 해킹당해도 공격자가 가짜 토큰을 위조할 수 없다. HMAC과는 근본적으로 다른 보안 구조다.
(서명 생성)"] PUB["공개키 🔓
(서명 검증만)"] O[주문 서비스] P[결제 서비스] D[배송 서비스] Auth --- PK O --- PUB P --- PUB D --- PUB end style PK fill:#f44336,stroke:#333,stroke-width:2px,color:#fff style PUB fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff
코드로 보면 이렇다.
// 인증 서버 — 개인키로 서명
public String generateToken(User user) {
return Jwts.builder()
.setSubject(user.getEmail())
.claim("user_id", user.getId())
.signWith(SignatureAlgorithm.RS256, privateKey)
.compact();
}
// API 서버 — 공개키로 검증
public Claims validateToken(String token) {
return Jwts.parser()
.setSigningKey(publicKey)
.parseClaimsJws(token)
.getBody();
}
- 서명에는
privateKey, 검증에는publicKey를 사용한다. 이것이 HMAC과의 핵심 차이다.
RSA의 트레이드오프
RSA가 보안적으로 우수하지만, 공짜는 아니다.
- 성능 — RSA 서명은 HMAC보다 느리다. 서명 생성은 약 10~100배, 검증은 약 2~5배 정도 차이가 난다
- 키 관리 — 개인키/공개키 쌍을 생성하고 관리해야 한다. 키 로테이션도 복잡하다
- 키 크기 — RSA 키는 최소 2048비트를 권장한다. HMAC 키(256비트)에 비해 훨씬 크다
단일 서버에서 RSA를 쓰면 성능 손해만 보고 보안 이점은 미미하다. 서비스 규모와 환경에 따라 적절한 선택이 달라진다.
알고리즘 선택 기준
세 가지 대표 알고리즘의 특성을 정리하면 이렇다.
| HS256 (HMAC) | RS256 (RSA) | ES256 (ECDSA) | |
|---|---|---|---|
| 키 방식 | 대칭키 | 비대칭키 | 비대칭키 |
| 서명 속도 | 빠름 | 느림 | 중간 |
| 키 크기 | 256비트 | 2048비트+ | 256비트 |
| 키 공유 | 모든 서버에 동일 키 | 인증 서버만 개인키 | 인증 서버만 개인키 |
| 보안 특성 | 검증 = 위조 가능 | 검증 ≠ 위조 | 검증 ≠ 위조 |
선택 기준은 명확하다.
- 단일 서버, 내부 API →
HS256— 구현이 간단하고, Secret Key를 공유할 대상이 자기 자신뿐이니 보안 리스크도 낮다 - 마이크로서비스, 외부 API →
RS256— 공개키만 배포하면 되니까 키 유출 리스크가 적고, 서비스 간 독립성이 보장된다 - 모바일, IoT →
ES256— RSA 수준의 보안을 훨씬 작은 키로 달성한다. 리소스가 제한된 환경에 적합하다
검증 전체 흐름
서버가 JWT를 받아서 검증하는 전체 과정을 정리하면 이렇다.
점(.)으로 나눠서
3개 파트가 나오는가? Note over Parser: 2단계 — 서명 검증
Header + Payload로
서명을 재계산해서
토큰의 서명과 비교 Note over Parser: 3단계 — 클레임 검증
exp: 만료되지 않았는가?
iss: 신뢰할 수 있는 발급자인가?
aud: 내 서비스를 위한 토큰인가? alt 모든 검증 통과 Parser-->>Server: Claims 객체 반환 Note over Server: user_id, role 등
클레임 활용 Server-->>Client: 200 OK else 검증 실패 Parser-->>Server: 예외 발생 Server-->>Client: 401 Unauthorized end
jjwt 라이브러리는 parseClaimsJws()를 호출하면 1단계(형식)와 2단계(서명), 그리고 exp 만료 검증까지 자동으로 처리한다. iss, aud 같은 추가 검증은 직접 코드를 작성해야 한다.
public Claims validateToken(String token) {
try {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
// 추가 검증: 발급자 확인
if (!"https://auth.example.com".equals(claims.getIssuer())) {
throw new InvalidTokenException("신뢰할 수 없는 발급자");
}
return claims;
} catch (ExpiredJwtException e) {
throw new TokenExpiredException("토큰이 만료되었다");
} catch (MalformedJwtException e) {
throw new InvalidTokenException("잘못된 토큰 형식이다");
} catch (SignatureException e) {
throw new InvalidTokenException("서명 검증에 실패했다");
}
}
parseClaimsJws()— 형식, 서명,exp를 한 번에 검증한다. 실패 시 상황별로 다른 예외를 던진다ExpiredJwtException— 만료된 토큰MalformedJwtException— 점 세 개로 나뉘지 않는 등 형식이 잘못된 토큰SignatureException— 서명이 일치하지 않는 변조된 토큰
이렇게 예외를 분리하면, 클라이언트에게 "만료된 토큰이니 재발급 받아라" vs "잘못된 토큰이니 다시 로그인해라" 같은 정확한 안내를 줄 수 있다.