JWT(JSON Web Token)는 점(.)을 구분자로 사용하는 세 부분으로 이루어져있다. Header.Payload.Signature

토큰의 '메타데이터'를 담고 있음.
{
"alg": "HS256",
"typ": "JWT"
"kid": "v-1.0.2-20260203"
}
토큰의 타입을 지정 (보통 JWT)
시그니처를 해싱하기 위한 알고리즘을 지정 (예: HS256, RS256)
- HS256 (HMAC + SHA256): 대칭키 방식. 하나의 비밀키(Secret Key)로 암호화와 복호화를 모두 수행
- RS256 (RSA + SHA256): 비대칭키 방식. OIDC에서 주로 사용하며, 공개키(Public Key)와 개인키(Private Key) 쌍을 이용(인증 서버는 개인키로 서명하고, 백엔드 앱은 공개키로 검증)
OIDC처럼 여러 개의 공개키를 돌려가며 사용하는 경우(Key Rotation), 어떤 키로 서명되었는지 식별하기 위해 사용. 백엔드 서버가 인증 서버로부터 여러 개의 키를 받아왔을 때, 헤더의 kid와 일치하는 키를 찾아 검증에 사용.
-> kid는 모든 JWT에 필수적인 요소는 아니기 때문에, 단순한 대칭키(HS256) 방식에서는 생략되는 경우가 많다. 하지만 OIDC나 대규모 서비스에서는 매우 중요한 역할을 함
헤더는 암호화되는 것이 아니라 Base64Url 방식으로 인코딩된다.
결과: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
이 결과값이 우리가 흔히 보는 JWT의 첫 번째 점(.) 이전의 문자열이 된다.
과거 일부 라이브러리에서 alg 값을 none으로 설정하면 서명 검증을 건너뛰는 취약점이 있었다.
공격 시나리오: 해커가 헤더의 alg를 none으로 바꾸고, 페이로드에서 role을 admin으로 수정한 뒤 서버에 보냄
방어: 최신 라이브러리들은 이를 기본적으로 막아두지만, 백엔드 로직에서 "기대하는 알고리즘(예: RS256)이 맞는지"를 명시적으로 체크하는 코드가 포함되어야 한다.
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
public void validateToken(String token, String secretKey) {
// 1. 우리가 기대하는 알고리즘을 상수로 정의 (예: HS256)
SignatureAlgorithm expectedAlg = SignatureAlgorithm.HS256;
try {
Jwts.parserBuilder()
.setSigningKey(secretKey.getBytes())
.build()
.parseClaimsJws(token); // 내부적으로 alg 체크 및 서명 검증 수행
// 만약 라이브러리가 기본으로 체크하지 않는 환경이라면
// 직접 헤더를 꺼내서 확인할 수도 있다.
String alg = Jwts.parserBuilder()
.setSigningKey(secretKey.getBytes())
.build()
.parse(token)
.getHeader()
.getAlgorithm();
if (!expectedAlg.getValue().equals(alg)) {
throw new SecurityException("알고리즘이 일치하지 않습니다!");
}
} catch (Exception e) {
// 유효하지 않은 토큰 처리
}
}
실제 전달할 데이터인 Claim(클레임)들이 들어 있다.
클레임이란 뭘까?
쉽게 말해 주체(사용자 등)에 대해 설정된 속성이나 진술.
이력서에 '이름: Elena', '희망 직무: 백엔드'라고 적는 것처럼, JWT라는 문서 안에 들어있는 key-value 쌍들이 바로 클레임
https://accounts.google.com)https://example.com/jwt_claims/is_admin: true{
"iss": "my-backend-server.com", // 등록된 클레임
"exp": 1738594800, // 등록된 클레임
"userId": "dev_12345", // 비공개 클레임
"role": "BACKEND_DEVELOPER" // 비공개 클레임
}
주의:
1. 페이로드는 누구나 열어볼 수 있고 암호화된 것이 아니라 단순 인코딩된 상태이므로, 비밀번호나 개인정보와 같은 민감한 정보는 절대 넣어서는 안 된다.
2. 클레임이 많아지면 토큰의 길이가 길어진다. JWT는 매 요청마다 HTTP 헤더에 담겨 전송되므로, 네트워크 부하를 고려해 꼭 필요한 데이터만 넣어야 한다.
시그니처는 단순히 데이터를 합치는 것이 아니라, 서버만 알고 있는 비밀키(Secret Key)를 사용해 암호화된 해시값을 생성.
Signature = HMACSHA256(base64UrlEncode(Header) + "." + base64UrlEncode(Payload), secret)JWT의 Header와 Payload는 누구나 디코딩해서 볼 수 있다. 만약 공격자가 Payload의 데이터를 몰래 수정한다면 어떻게 될까?
공격자: Payload의 "role": "user"를 "role": "admin"으로 바꿈
서버의 검증: 서버는 받은 토큰의 (수정된) Payload와 본인의 비밀키를 넣어 시그니처를 다시 계산
결과: 공격자는 서버의 비밀키를 모르기 때문에, 서버가 새로 계산한 시그니처와 토큰에 붙어온 시그니처가 일치하지 않게 된다.
서버의 판단: "이 토큰은 오염되었다!"라며 요청을 거부
즉, 시그니처는 "누군가 내용을 고쳤는가?"와 "이 토큰을 발행한 게 정말 우리 서버가 맞는가?"를 확인하는 장치
백엔드에서 시그니처를 다룰 때 가장 중요한 선택지
원리: 하나의 Secret Key로 서명하고 검증
특징: 속도가 빠르지만, 토큰을 검증해야 하는 모든 서버가 동일한 Secret Key를 공유해야 한다. 키가 유출되면 시스템 전체가 위험해짐
원리: 개인키(Private Key)로 서명하고, 공개키(Public Key)로 검증
특징:
* 인증 서버(Auth Server)만 개인키를 가진다.
* 백엔드 서버(API Server)는 공개키만 가집니다.
* 공개키는 말 그대로 공개되어도 상관없으므로 훨씬 안전하며, OIDC 서비스(Google, Kakao 등)에서 기본으로 사용
서버는 요청을 받을 때마다 다음 순서로 검증을 수행해야 한다.
JWT의 가장 큰 약점은 서버가 상태를 저장하지 않기 때문에(Stateless), 한 번 발급된 토큰은 만료 전까지 제어하기 어렵다는 점
| 구분 | Access Token | Refresh Token |
|---|---|---|
| 목적 | 리소스 접근(API 호출)을 위한 신분증 | Access Token을 재발급받기 위한 보증서 |
| 유효 기간 | 매우 짧음 (예: 30분 ~ 1시간) | 상대적으로 긺 (예: 2주 ~ 한 달) |
| 저장 위치 | 클라이언트 메모리 / 쿠키 | 서버 DB / Redis / 보안 쿠키 |
가장 강력한 보안 전략 중 하나
원리: Refresh Token을 사용하여 Access Token을 재발급받을 때, 기존 Refresh Token도 폐기하고 새로운 Refresh Token을 발급
효과: 만약 공격자가 Refresh Token을 탈취하더라도, 사용자가 먼저 토큰을 갱신해버리면 공격자의 토큰은 무효화. 또한, 이미 사용된 토큰으로 재요청이 들어오면 서버는 "토큰 탈취"로 간주하고 해당 사용자의 모든 세션을 강제 종료할 수 있다.
@Transactional
public TokenResponseDto rotateRefreshToken(String oldRefreshToken) {
// 1. 기존 리프레시 토큰으로 사용자 정보 추출
String userId = jwtProvider.getUserId(oldRefreshToken);
// 2. Redis(또는 DB)에 저장된 토큰과 일치하는지 확인
String savedToken = redisService.getData("RT:" + userId);
if (!oldRefreshToken.equals(savedToken)) {
// 일치하지 않으면 탈취로 간주하고 모든 토큰 삭제 (보안 조치)
redisService.deleteData("RT:" + userId);
throw new RuntimeException("비정상적인 접근입니다. 다시 로그인하세요.");
}
// 3. 새로운 AT, RT 생성
String newAccessToken = jwtProvider.createAccessToken(userId);
String newRefreshToken = jwtProvider.createRefreshToken(userId);
// 4. Redis 정보 갱신 (기존 토큰 무효화 및 새 토큰 저장)
redisService.setDataWithExpiration("RT:" + userId, newRefreshToken, refreshExpirationTime);
return new TokenResponseDto(newAccessToken, newRefreshToken);
}
JWT는 Stateless가 장점이지만, Refresh Token만큼은 서버(DB나 Redis)에 저장하는 것이 정석
@Service
@RequiredArgsConstructor
public class RedisService {
private final RedisTemplate<String, String> redisTemplate;
// 토큰 저장 (Key: 사용자ID, Value: 리프레시 토큰)
public void setDataWithExpiration(String key, String value, Long time) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.MILLISECONDS);
}
// 토큰 삭제 (로그아웃 처리 시 호출)
public void deleteData(String key) {
redisTemplate.delete(key);
}
}
토큰을 브라우저의 LocalStorage에 저장하면 XSS(Cross-Site Scripting) 공격에 취약
public void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true) // JS에서 접근 불가 (XSS 방지)
.secure(true) // HTTPS에서만 전송
.path("/") // 모든 경로에서 사용
.maxAge(14 * 24 * 60 * 60) // 14일간 유효
.sameSite("Strict") // CSRF 공격 방지
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}