지난 시리즈에서 Authorization Code를 주고받아 백엔드 서버가 ID Token(JWT)을 손에 넣었다. 하지만 이 토큰이 진짜 인증 서버(Google, Kakao 등)가 보낸 것인지, 아니면 해커가 교묘하게 만든 가짜인지 확인하는 과정이 반드시 필요하다.
JWT에서 학습했듯이 토큰의 마지막 부분은 signature이다.(Header.payload.signature) OIDC에서는 보안을 위해 주로 비대칭 키 방식(RS256)을 사용한다.
인증 서버(Google 등): 자신들만 아는 비밀키(Private Key)로 토큰에 서명을 해서 보냄
나의 백엔드 서버: 인증 서버가 공개한 공개키(Public Key)를 가져와서 서명을 검증
-> 여기서 문제는 "인증 서버의 공개키를 어디서, 어떻게 가져오는가?" 그 정답이 바로 JWKS
구글 OIDC 설정 엔드포인트
https://accounts.google.com/.well-known/openid-configuration
핵심 필드
issuer: "https://accounts.google.com"
-> ID Token을 검증할 때 iss 클레임과 대조해야 할 기준 값
authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth"
-> 사용자를 로그인시키기 위해 리다이렉트 보내야 할 주소
token_endpoint: "https://oauth2.googleapis.com/token"
-> Authorization Code를 주고 진짜 토큰을 받아오는 주소
jwks_uri: "https://www.googleapis.com/oauth2/v3/certs"
-> 가장 중요한 필드. n, e, kid를 가져오기 위해 호출해야 할 실제 공개키 목록 주소
Tip: Spring Security OAuth2 Client를 사용할 때, 위에서 언급한 개별 주소들을 일일이 등록하지 않아도 된다.
issuer-uri에 https://accounts.google.com만 딱 적어주면, 스프링이 알아서 뒤에 /.well-known/openid-configuration을 붙여 호출한 뒤, 필요한 모든 엔드포인트 주소를 자동으로 찾아낸다.
인증 서버가 "우리 서버의 공개키 목록은 여기 있어!"라고 공개해둔 엔드포인트
URL 형태: 보통 https://www.googleapis.com/oauth2/v3/certs 같은 주소
데이터 형태: 여러 개의 공개키가 JSON 배열 형태로 들어있다. 서버는 보안을 위해 주기적으로 키를 변경(Rotation)하기 때문에 여러 개가 존재할 수 있음
{
"keys": [
{
"kid": "8f8e9...", // 키 식별자 (ID Token의 헤더에 박힌 값과 매칭)
"kty": "RSA",
"alg": "RS256",
"n": "...", // 공개키 모듈러스
"e": "AQAB" // 공개키 지수
}
]
}
kty(Key Type)
- 의미: 이 키가 어떤 알고리즘 패밀리를 사용하는지 나타냄
- OIDC에서의 값: 보통 RSA입니다.
- 역할: 백엔드에서 키 객체를 생성할 때 KeyFactory.getInstance("RSA") 처럼 어떤 엔진을 돌릴지 결정하는 지표가 됨
n(Modulus, 모듈러스)
- 의미: 두 개의 아주 큰 소수()를 곱한 결과값 ()
- 비유: RSA라는 금고의 '몸체'와 같다
- 특징: 아주 큰 숫자이며, 이 숫자를 다시 원래의 두 소수로 쪼개는 것(소인수분해)이 현대 컴퓨터로는 거의 불가능하다는 점이 RSA 보안의 핵심. JWKS에서는 이 거대한 숫자를 안전하게 전달하기 위해 Base64URL로 인코딩해서 보냄
e (Exponent, 지수)
- 의미: 공개키 지수라고 하며, 암호화나 서명 검증에 사용되는 숫자
- 특징: 보안상의 이유와 계산 효율을 위해 보통 65537 (Base64URL로 AQAB)이라는 고정된 값을 주로 사용.
- 비유: 금고 몸체()와 함께 세트를 이루는 '열쇠의 모양'
우리가 사용하는 라이브러리(jjwt, Nimbus-ds 등)는 내부적으로 다음과 같은 과정을 거친다.
1. JWKS에서 문자열로 된 n과 e를 가져온다
2. 이 문자열들을 숫자로 변환한다
3. 이 두 숫자()를 합쳐서 자바의 RSAPublicKeySpec이라는 설계도를 만든다
4. 이 설계도를 기반으로 PublicKey라는 실제 열쇠 객체를 생성한다
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
public class KeyGenerator {
public PublicKey createPublicKey(String nStr, String eStr) throws Exception {
// 1. Base64URL 인코딩된 n과 e를 byte 배열로 디코딩
byte[] nBytes = Base64.getUrlDecoder().decode(nStr);
byte[] eBytes = Base64.getUrlDecoder().decode(eStr);
// 2. byte 배열을 양수(Positive) BigInteger로 변환
// (RSA 파라미터는 항상 양수여야 하므로 signum을 1로 설정)
BigInteger n = new BigInteger(1, nBytes);
BigInteger e = new BigInteger(1, eBytes);
// 3. RSA 공개키 설계도(Spec) 생성
RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
// 4. RSA 알고리즘 엔진을 통해 실제 PublicKey 객체 생성
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(publicKeySpec);
}
}
// Map을 활용한 간단한 공개키 캐싱 예시
private Map<String, PublicKey> keyCache = new ConcurrentHashMap<>();
public PublicKey getPublicKey(String kid, String n, String e) throws Exception {
if (!keyCache.containsKey(kid)) {
PublicKey publicKey = createPublicKey(n, e);
keyCache.put(kid, publicKey);
}
return keyCache.get(kid);
}
매번 토큰을 검증할 때마다 인증 서버의 JWKS 엔드포인트를 호출하는 것은 성능상 매우 좋지 않음 (네트워크 비용 발생)
해결책: 처음 가져온 공개키 목록을 서버 메모리(또는 Redis)에 캐싱해두고 사용
주의: 만약 토큰의 kid에 해당하는 키가 캐시에 없다면, 그때만 다시 JWKS를 호출해서 캐시를 갱신 (Key Rotation 대응)
헤더에서 kid 추출: 받은 ID Token의 헤더를 열어 어떤 키(kid)로 서명되었는지 확인
공개키 로드 (JWKS): 인증 서버의 JWKS 엔드포인트에서 키 목록을 가져옴
매칭되는 키 찾기: 목록 중 ID Token의 kid와 일치하는 공개키를 선택
서명 검증: 선택한 공개키로 토큰의 서명이 유효한지 계산
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import java.security.PublicKey;
public class JwtValidator {
public Claims validateSignature(String idToken, PublicKey publicKey) {
try {
// 1. JWT parser 빌드 (가져온 공개키 설정)
Jws<Claims> jws = Jwts.parser()
.verifyWith(publicKey) // 위에서 만든 공개키 주입
.build()
// 2. 토큰 파싱 및 서명 검증 수행
.parseSignedClaims(idToken);
// 3. 검증 성공 시 토큰 내부 데이터(Claims) 반환
return jws.getPayload();
} catch (io.jsonwebtoken.security.SignatureException e) {
// 서명이 일치하지 않는 경우 (위변조 가능성)
throw new RuntimeException("서명이 유효하지 않습니다.");
} catch (io.jsonwebtoken.ExpiredJwtException e) {
// 토큰 유효기간이 만료된 경우
throw new RuntimeException("토큰이 만료되었습니다.");
} catch (Exception e) {
// 그 외 형식 오류 등
throw new RuntimeException("토큰 검증 중 오류가 발생했습니다.");
}
}
}
- Jws(JSON Web Signature)
JWT의 규격 중에서 서명(Signature)을 더해 데이터의 무결성(변조되지 않음)을 보장하는 구체적인 형태
"내용물을 JSON으로 담자!"는 약속은 JWT이고, "그 내용이 진짜인지 도장을 찍자!"는 기술이 JWS
Jws<Claims>의 의미는 파싱 결과가 단순한 Claims(내용물)가 아니라 서명 검증이 완료된(Jws) Claims임을 의미
.parseSignedClaims(idToken) 메서드가 호출될 때 일어나는 일
해싱: 토큰의 Header와 Payload를 합쳐서 알고리즘(예: RS256)에 따라 해시값을 만든다
복호화: 토큰에 붙어온 Signature를 PublicKey로 복호화한다
대조: 해시값과 복호화된 값이 정확히 일치하는지 확인한다
결론: 값이 일치한다면, 이 토큰은 해당 공개키와 쌍을 이루는 비밀키(인증 서버가 소유)로만 만들어졌음이 수학적으로 증명된다
서명이 맞다고 끝이 아님. 앞서 배운 nonce, iss, aud 값을 확인해야 비로소 검증이 끝난다
iss (Issuer): 구글이 보낸 게 맞는가?
aud (Audience): 나의 앱을 위해 발행된 게 맞는가?
exp (Expiration): 유효기간이 지나지 않았는가?
nonce: 내가 요청할 때 보낸 그 값이 맞는가?
public void finalCheck(Claims claims, String expectedNonce) {
// 발행자 확인
if (!claims.getIssuer().contains("accounts.google.com")) {
throw new RuntimeException("허용되지 않은 발행자입니다.");
}
// 대상자 확인 (나의 서비스용인가?)
if (!claims.getAudience().equals("YOUR_CLIENT_ID")) {
throw new RuntimeException("이 토큰은 나의 서비스용이 아닙니다.");
}
// Nonce 확인 (리플레이 공격 방지)
if (!claims.get("nonce").equals(expectedNonce)) {
throw new RuntimeException("Nonce 값이 일치하지 않습니다.");
}
}