OIDC - JWT

Elena·2026년 2월 3일

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

1. Header (헤더)

토큰의 '메타데이터'를 담고 있음.

{
  "alg": "HS256",
  "typ": "JWT"
  "kid": "v-1.0.2-20260203"
}

1. typ

토큰의 타입을 지정 (보통 JWT)

2. alg(Algorithm)

시그니처를 해싱하기 위한 알고리즘을 지정 (예: HS256, RS256)

  • HS256 (HMAC + SHA256): 대칭키 방식. 하나의 비밀키(Secret Key)로 암호화와 복호화를 모두 수행
  • RS256 (RSA + SHA256): 비대칭키 방식. OIDC에서 주로 사용하며, 공개키(Public Key)와 개인키(Private Key) 쌍을 이용(인증 서버는 개인키로 서명하고, 백엔드 앱은 공개키로 검증)

3. kid (Key ID)

OIDC처럼 여러 개의 공개키를 돌려가며 사용하는 경우(Key Rotation), 어떤 키로 서명되었는지 식별하기 위해 사용. 백엔드 서버가 인증 서버로부터 여러 개의 키를 받아왔을 때, 헤더의 kid와 일치하는 키를 찾아 검증에 사용.
-> kid는 모든 JWT에 필수적인 요소는 아니기 때문에, 단순한 대칭키(HS256) 방식에서는 생략되는 경우가 많다. 하지만 OIDC나 대규모 서비스에서는 매우 중요한 역할을 함

헤더는 어떻게 만들어질까? (Encoding)

헤더는 암호화되는 것이 아니라 Base64Url 방식으로 인코딩된다.

  1. JSON 형태의 헤더를 만든다: {"alg":"HS256","typ":"JWT"}
  2. 이를 공백 없이 문자열로 바꾼다.
  3. Base64Url 인코딩을 수행한다.

결과: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

이 결과값이 우리가 흔히 보는 JWT의 첫 번째 점(.) 이전의 문자열이 된다.

개발자가 주의해야 할 'alg: none' 공격

과거 일부 라이브러리에서 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) {
        // 유효하지 않은 토큰 처리
    }
}

2. Payload (페이로드)

실제 전달할 데이터인 Claim(클레임)들이 들어 있다.

클레임이란 뭘까?
쉽게 말해 주체(사용자 등)에 대해 설정된 속성이나 진술.
이력서에 '이름: Elena', '희망 직무: 백엔드'라고 적는 것처럼, JWT라는 문서 안에 들어있는 key-value 쌍들이 바로 클레임

클레임의 세 가지 종류

  1. 등록된 클레임 (Registered Claims): 서비스에서 필수적인 것은 아니지만, 상호 운용성(연동)을 위해 이미 이름이 예약된 클레임. 이름이 3글자로 매우 짧은 것이 특징
  • iss (Issuer): 토큰 발급자 (예: https://accounts.google.com)
  • sub (Subject): 토큰 제목, 보통 사용자의 유니크한 ID값
  • aud (Audience): 토큰 대상자 (이 토큰을 받아 처리할 서비스)
  • exp (Expiration Time): 토큰 만료 시간 (NumericDate 형식)
  • iat (Issued At): 토큰 발급 시간
  1. 공개 클레임 (Public Claims): 사용자 마음대로 정의할 수 있지만, 충돌을 방지하기 위해 보통 URI 형식으로 이름을 짓는다.
  • https://example.com/jwt_claims/is_admin: true
  1. 비공개 클레임 (Private Claims): 서버와 클라이언트가 협의하에 사용하는 커스텀 데이터. 실무에서 가장 많이 활용
  • "role": "admin"
  • "email": "user@test.com"
  • "nickname": "초보개발자"
{
  "iss": "my-backend-server.com",    // 등록된 클레임
  "exp": 1738594800,                // 등록된 클레임
  "userId": "dev_12345",            // 비공개 클레임
  "role": "BACKEND_DEVELOPER"       // 비공개 클레임
}

주의:
1. 페이로드는 누구나 열어볼 수 있고 암호화된 것이 아니라 단순 인코딩된 상태이므로, 비밀번호나 개인정보와 같은 민감한 정보는 절대 넣어서는 안 된다.
2. 클레임이 많아지면 토큰의 길이가 길어진다. JWT는 매 요청마다 HTTP 헤더에 담겨 전송되므로, 네트워크 부하를 고려해 꼭 필요한 데이터만 넣어야 한다.

3. Signature (서버 검증 인장)

1. 시그니처가 만들어지는 원리

시그니처는 단순히 데이터를 합치는 것이 아니라, 서버만 알고 있는 비밀키(Secret Key)를 사용해 암호화된 해시값을 생성.

  • 생성 공식
    Signature = HMACSHA256(base64UrlEncode(Header) + "." + base64UrlEncode(Payload), secret)
  1. 인코딩된 Header와 Payload를 점(.)으로 연결
  2. 헤더에 명시된 알고리즘(예: HS256)을 사용
  3. 서버의 비밀키(Secret Key)를 넣어 해싱
  4. 그 결과값을 다시 Base64Url로 인코딩하면 시그니처가 완성

2. 시그니처의 존재 이유: 무결성(Integrity) 보장

JWT의 Header와 Payload는 누구나 디코딩해서 볼 수 있다. 만약 공격자가 Payload의 데이터를 몰래 수정한다면 어떻게 될까?

  • 공격자: Payload의 "role": "user"를 "role": "admin"으로 바꿈

  • 서버의 검증: 서버는 받은 토큰의 (수정된) Payload와 본인의 비밀키를 넣어 시그니처를 다시 계산

  • 결과: 공격자는 서버의 비밀키를 모르기 때문에, 서버가 새로 계산한 시그니처와 토큰에 붙어온 시그니처가 일치하지 않게 된다.

  • 서버의 판단: "이 토큰은 오염되었다!"라며 요청을 거부

즉, 시그니처는 "누군가 내용을 고쳤는가?"와 "이 토큰을 발행한 게 정말 우리 서버가 맞는가?"를 확인하는 장치

3. 대칭키(HS256) vs 비대칭키(RS256)

백엔드에서 시그니처를 다룰 때 가장 중요한 선택지

  1. HS256 (대칭키 방식)
  • 원리: 하나의 Secret Key로 서명하고 검증

  • 특징: 속도가 빠르지만, 토큰을 검증해야 하는 모든 서버가 동일한 Secret Key를 공유해야 한다. 키가 유출되면 시스템 전체가 위험해짐

  1. RS256 (비대칭키 방식) - OIDC 표준
  • 원리: 개인키(Private Key)로 서명하고, 공개키(Public Key)로 검증

  • 특징:

    	* 인증 서버(Auth Server)만 개인키를 가진다.
    
    	* 백엔드 서버(API Server)는 공개키만 가집니다.
    
    	* 공개키는 말 그대로 공개되어도 상관없으므로 훨씬 안전하며, OIDC 서비스(Google, Kakao 등)에서 기본으로 사용

4. TIP. 시그니처 검증 시점

서버는 요청을 받을 때마다 다음 순서로 검증을 수행해야 한다.

  1. 형식 검사: 점(.)이 두 개인지, 형식이 맞는지 확인
  2. 시그니처 검증: 가장 먼저 수행. 시그니처가 틀리면 내부 데이터를 열어볼 가치도 없다
  3. 유효 기간(exp) 검증: 시그니처가 맞아도 만료된 토큰인지 확인
  4. 권한 확인: Payload의 role 등을 확인하여 비즈니스 로직 수행

5. Access Token과 Refresh Token 보안 전략

JWT의 가장 큰 약점은 서버가 상태를 저장하지 않기 때문에(Stateless), 한 번 발급된 토큰은 만료 전까지 제어하기 어렵다는 점

1.Access Token vs Refresh Token

구분Access TokenRefresh Token
목적리소스 접근(API 호출)을 위한 신분증Access Token을 재발급받기 위한 보증서
유효 기간매우 짧음 (예: 30분 ~ 1시간)상대적으로 긺 (예: 2주 ~ 한 달)
저장 위치클라이언트 메모리 / 쿠키서버 DB / Redis / 보안 쿠키

2. 핵심 보안 전략

1) RTR (Refresh Token Rotation)

가장 강력한 보안 전략 중 하나

  • 원리: 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);
}

2) Refresh Token의 서버 저장 (Redis 활용)

JWT는 Stateless가 장점이지만, Refresh Token만큼은 서버(DB나 Redis)에 저장하는 것이 정석

  • 이유: 사용자가 로그아웃하거나 계정이 정지되었을 때, 서버에서 해당 Refresh Token을 삭제함으로써 즉시 접근 권한을 박탈(Revoke)하기 위함
@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) 공격에 취약

  • 전략: 토큰을 쿠키에 담아 전달하되, HttpOnly 옵션을 주어 자바스크립트가 접근하지 못하게 하고, Secure 옵션으로 HTTPS 통신에서만 전송되도록 설정
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());
}

3. Workflow

  1. 로그인: 사용자가 로그인하면 서버는 Access(AT)와 Refresh(RT) 토큰을 모두 발급
  2. API 호출: 클라이언트는 AT를 담아 요청을 보냄
  3. AT 만료: 서버가 401 Unauthorized (Token Expired)를 응답
  4. 토큰 갱신: 클라이언트는 저장해둔 RT를 서버에 보냄
  5. 검증 및 재발급: 서버는 DB의 RT와 대조한 뒤, 새 AT와 새 RT를 다시 발급(RTR 적용)
profile
一切唯心造

0개의 댓글