Adding a higher-priority RSA provider leaves both kids in JWKS, so tokens signed before and after the rotation both validate. Deleting the old provider makes its tokens 401 immediately, and the resource server's JWKS cache does not buy a grace period because an unknown kid triggers a refetch. The encryption key Q3 asks about does not exist yet, since B-2 showed the tokens are stored as plaintext JWTs, so the measured signing-key rotation is what its design has to copy: write with one key, read with several, and keep the overlap longer than the lifetime of anything signed with the old one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.5 KiB
B-6 — key 를 교체하면 옛 것으로 만든 것은 어떻게 되는가 → Q3 미지수 3
브랜치 feature/keycloak-b6-key-rotation ·
증거 docs/evidence/b6-key-rotation/ ·
2026-09-04 15:50–16:00 KST
선행: B-2 — 토큰이 평문임을 확인했다
대응 질문 — Q3 미지수 3
"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로 저장된 값은 어떻게 읽는가."
0. 결론부터
질문이 두 갈래로 나뉜다.
| 상태 | |
|---|---|
| ① 토큰 저장소의 암호화 key | ★ 존재하지 않는다. B-2 에서 평문임을 확인했다 |
| ② 토큰 서명 key (Keycloak realm) | 존재하고 회전 가능하다 — 측정했다 |
②를 측정한 결과가 ①을 설계할 때 그대로 쓰인다.
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
"교체하는 동안 이전 key 로 저장된 값은 어떻게 읽는가" 의 답은 "두 key 를 동시에 들고 있으면 읽힌다" 이고, 위험한 것은 교체가 아니라 옛 key 를 언제 버리느냐다.
1. ① 암호화 key 는 애초에 없다
B-2 에서 확인했다.
select convert_from(refresh_token_value, 'UTF8') from oauth2_authorized_client;
→ eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUz...
bytea 안이 JWT 문자열 그대로다. 암호화가 없으므로 교체할 key 도 없다.
Q3 는 "Access Token 과 Refresh Token 을 Redis 에 저장할 경우 Token 을 어떤 방식으로 암호화할지" 를 미지수로 뒀다. 먼저 확인해야 할 것은 "지금은 암호화가 없다" 이고, 그것을 B-2 에서 측정했다.
암호화를 넣는다면 회전 설계는 ②에서 본 모양을 따라야 한다 — 읽기는 여러 key 를 받아들이고, 쓰기는 한 key 만 쓴다.
2. ② 서명 key 회전을 실측한다
회전 전
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
JWKS 의 RS256 키 수: 1
/api/me HTTP 200
회전 — 우선순위가 높은 공급자를 추가한다
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create components \
-r keycloak-patterns \
-s name=rsa-rotated -s providerId=rsa-generated \
-s providerType=org.keycloak.keys.KeyProvider \
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
Keycloak 의 키 회전은 "바꾸기"가 아니라 "더 높은 우선순위로 추가하기"다.
RS256 키 수: 1 → 2
kid 목록:
1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84 ← 새 키 (우선순위 200)
OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM ← 옛 키 (우선순위 100, 남아 있다)
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
결과 — 무중단이다
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
둘 다 통한다. 새 토큰은 새 키로 서명되고, 옛 토큰은 JWKS 에 아직 있는 옛 키로 검증된다.
3. 옛 키를 제거하면 — 여기가 진짜 질문이다
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/980ee9b7-... -r keycloak-patterns
RS256 키 수: 2 → 1
옛 토큰 /api/me HTTP 401 ← ★ 즉시 깨진다
새 토큰 /api/me HTTP 200
리소스 서버를 재시작해 JWKS 캐시를 비운 뒤에도 같다.
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
캐시가 구해주지 않는다
처음에는 "리소스 서버가 JWKS 를 캐시하고 있으니 한동안은 통하겠지" 라고 예상했다. 아니었다. 제거 직후 바로 401 이다.
이유는 Spring 의 NimbusJwtDecoder 동작에 있다 —
모르는 kid 를 만나면 JWKS 를 다시 가져온다. 그래서 캐시가 오히려
빨리 갱신되고, 결과적으로 제거가 즉시 반영된다.
캐시를 유예 기간으로 기대하면 안 된다. 유예는 옛 키를 JWKS 에 남겨두는 기간으로 만들어야 한다.
4. 개념
kid — key ID
{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
토큰 헤더에 어느 키로 서명했는지가 적혀 있다. 검증하는 쪽은 JWKS 에서
그 kid 를 찾아 공개키를 얻는다.
kid 가 있기 때문에 여러 키를 동시에 운용할 수 있다.
안전한 회전의 모양
t0 키 A 만 있다. 발급: A, 검증: A
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
t2 키 A 제거. 발급: B, 검증: B
★ t1 ~ t2 사이가 "A 로 서명된 것이 살아 있는 기간" 보다 길어야 한다
겹치는 구간의 최소 길이 = 옛 키로 서명된 것 중 가장 오래 사는 것의 수명.
| 이 실험대에서 | |
|---|---|
| access token | 60초 |
| refresh token | 1800초 (30분) |
| 필요한 겹침 | 최소 30분 |
Q3 가 물은 "교체하는 동안" 이 바로 이 구간이고, 길이를 정하는 것은 key 가 아니라 그 key 로 만든 것의 수명이다.
①에 적용하면
토큰 저장소를 암호화한다면 같은 구조가 필요하다.
쓰기: 새 key 하나로만
읽기: 새 key + 옛 key(들) ← key 에도 식별자가 필요하다
제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤
저장된 값에 kid 에 해당하는 표시가 없으면 회전이 불가능하다.
암호화를 설계할 때 key 식별자를 값과 함께 저장해야 하는 이유다.
5. 겪은 문제
kcadm get components 가 조용히 빈 결과
kcadm.sh get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider
-q type=... 로는 아무것도 안 나왔다. --fields 를 붙여 전체를 받아야 보였다.
필터가 안 먹었는데 오류도 없었다 — A층에서 반복해 만난 "조용한 실패"다.
6. 재현 절차 (명령어)
# 1. 현재 키와 토큰의 kid
kcadm.sh get keys -r keycloak-patterns
curl -s $KC/protocol/openid-connect/certs | tr ',' '\n' | grep kid
# 2. 회전 — 더 높은 우선순위로 추가한다 (바꾸는 것이 아니다)
kcadm.sh create components -r keycloak-patterns \
-s name=rsa-rotated -s providerId=rsa-generated \
-s providerType=org.keycloak.keys.KeyProvider \
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
# 3. 옛 토큰이 아직 통하는지 (통해야 정상)
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OLD" http://echo...:8081/api/me
# 4. 옛 공급자 목록 — -q 필터는 안 먹는다. --fields 로 전체를 본다
kcadm.sh get components -r keycloak-patterns --fields id,name,providerId
# 5. 제거하고 다시 확인 — 여기서 401 이 나와야 정상이다
kcadm.sh delete components/<old-id> -r keycloak-patterns
7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| B-7 oauth2-proxy cookie secret | 같은 모양의 문제. 다만 거기는 겹침 구간을 만들 수 있는가가 관건 |
| D-2 버전 업그레이드 | Redis 의 Java 직렬화 세션(B-1)도 같은 "옛 형식을 읽을 수 있는가" 문제다 |
| 설계 | 암호화를 넣는다면 값과 함께 key 식별자를 저장해야 회전할 수 있다 |