LabHub
배우기 러닝패스 코스

Keycloakと企業認証

JWTをサーバに問い合わせずに検証する

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

JWT 검증은 인증 서버에 물어보는 것이 아니라, 공개키로 서명을 확인하고 클레임을 검사하는 로컬 연산이다. 그래서 빠르다.

概念マップ: 다른 서비스용 토큰이 통과한다・aud 를 빠뜨리는 것이 가장 흔합니다.・회전 중에는 옛 키와 새 키가 함께 유효

왜 이게 필요했나

토큰이 유효한지 확인하는 방법은 두 가지입니다. 인증 서버의 introspection 엔드포인트에 물어보거나, 토큰 자체의 서명을 검증하는 것입니다.

전자는 정확합니다. 방금 취소된 토큰도 즉시 무효로 나옵니다. 대신 매 요청마다 네트워크 왕복이 생깁니다. 마이크로서비스 열 개가 각각 물어보면 인증 서버가 병목이 됩니다.

후자는 네트워크 없이 끝납니다. 공개키만 있으면 되고, 공개키는 캐시할 수 있습니다. 대신 취소를 즉시 반영하지 못합니다. 토큰이 만료될 때까지는 유효한 것으로 보입니다.

실무의 답은 정해져 있습니다. 액세스 토큰을 짧게(5~15분) 두고 로컬 검증을 씁니다. 취소 반영 지연을 그 수명만큼으로 제한하는 것입니다. 리프레시 토큰 회전을 함께 쓰면 블랙리스트 없이도 실질적 무효화 효과를 얻습니다.

어떻게 동작하나

JWT 는 점으로 구분된 세 부분입니다. 헤더, 페이로드, 서명. 앞의 둘은 Base64URL 인코딩일 뿐 암호화가 아닙니다. 누구나 읽을 수 있습니다. 그래서 JWT 에 비밀을 담으면 안 됩니다.

검증 순서는 이렇습니다.

먼저 헤더의 algkid 를 봅니다. algnone 이거나 예상과 다르면 즉시 거부합니다. 이것을 확인하지 않아 서명 우회가 일어난 사고가 실제로 있습니다.

kid 로 JWKS 엔드포인트에서 해당 공개키를 찾습니다. JWKS 는 캐시하되 kid 를 못 찾으면 한 번 갱신합니다 — 키 회전 때 필요합니다.

서명을 검증합니다. RS256 이면 공개키로 RSA 검증입니다.

그다음 클레임을 봅니다. exp(만료), iss(발급자가 우리가 아는 그 서버인가), aud(이 토큰이 우리 API 를 위한 것인가), 그리고 필요하면 nbfazp 입니다.

aud 검사를 빠뜨리는 실수가 특히 흔합니다. 같은 인증 서버가 발급한 다른 API 용 토큰이 우리 API 에서 통과하게 됩니다.

ID 토큰과 액세스 토큰을 혼동하는 것도 흔합니다. ID 토큰은 클라이언트 앱이 "사용자가 누구인지" 확인하기 위한 것이고 aud 가 클라이언트입니다. API 호출에는 액세스 토큰을 써야 합니다.

현장에서 만나는 모습

키 회전이 있으면 JWKS 에 여러 키가 동시에 존재합니다. kid 로 고르는 이유입니다. 캐시 TTL 을 너무 길게 잡으면 회전 직후 모든 검증이 실패하고, 너무 짧으면 인증 서버로 요청이 몰립니다. 보통 몇 시간 캐시하되 kid 미스 시 즉시 갱신하는 방식을 씁니다.

검증에서 반드시 확인할 다섯 가지

서명만 맞으면 되는 것이 아닙니다. 다섯 개를 다 봐야 합니다.

클레임 확인 안 보면
iss 우리가 아는 발급자인가 다른 곳이 발급한 토큰이 통과한다
aud 이 서비스를 대상으로 하는가 다른 서비스용 토큰이 통과한다
exp 만료되지 않았는가 영원히 유효해진다
nbf 아직 유효 기간 전인가 미래 토큰이 통과한다
서명 발급자의 키로 서명됐는가 위조 토큰이 통과한다

aud 를 빠뜨리는 것이 가장 흔합니다. 같은 인증 서버를 쓰는 다른 애플리케이션의 액세스 토큰으로 우리 API 를 부를 수 있게 됩니다. 마이크로서비스가 여럿이면 반드시 확인합니다.

알고리즘 혼동 공격

라이브러리에 알고리즘을 맡기면 안 됩니다. 토큰 헤더의 alg 를 그대로 믿는 순간 공격자가 그것을 none 이나 HS256 으로 바꿔 보냅니다.

정상: {"alg": "RS256", "kid": "abc"}   ← 공개키로 검증
공격: {"alg": "none"}                   ← 서명 없이 통과시키려는 시도
공격: {"alg": "HS256"}                  ← 공개키를 HMAC 비밀키로 쓰게 유도

두 번째가 특히 교묘합니다. RS256 의 공개키는 누구나 아는 값인데, 그것을 HMAC 의 대칭키로 쓰면 공격자가 유효한 서명을 만들 수 있습니다.

# ❌ 헤더를 믿는다
jwt.decode(token, key)

# ✅ 우리가 알고리즘을 정한다
jwt.decode(token, key, algorithms=["RS256"], audience="labhub-api",
           issuer="https://auth.labhub/realms/labhub")

키 회전과 JWKS 캐시

인증 서버는 키를 주기적으로 바꿉니다. 애플리케이션은 kid(키 식별자)로 맞는 공개키를 고르고, 모르는 kid 를 만나면 JWKS 를 다시 받습니다.

1. 토큰 헤더의 kid 를 본다
2. 캐시에 있으면 그 키로 검증
3. 없으면 JWKS 엔드포인트를 다시 받는다 (여기에 속도 제한을 건다)

3번에 제한이 없으면 모르는 kid 를 담은 요청을 쏟아부어 인증 서버를 무너뜨릴 수 있습니다. 분당 몇 회로 제한하고, 그 사이에는 캐시된 키만 씁니다.

캐시 수명은 짧게(5~15분) 두되, 회전 중에는 옛 키와 새 키가 함께 유효 해야 합니다. 인증 서버가 두 키를 함께 게시하는 기간을 두는 이유입니다.

다음 실습에서 할 것

Keycloak 에서 토큰을 받아 세 부분으로 뜯어보고, 클레임을 확인하고, JWKS 에서 키를 찾아 서명을 검증하고, 페이로드를 변조해 검증이 실패하는 것까지 확인합니다. 마지막에 introspection 과 로컬 검증의 소요 시간을 100회씩 비교합니다.