Files
document-haness/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b6-key-rotation.md
T
DongHyeonkaandClaude Opus 5 32e39e20aa fix(setup): 실험대에서 35편을 끝까지 밟고 어긋난 명령·결과 31건을 고친다
test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 ·
keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를
기록과 SSOT 양쪽에 실측과 함께 넣었다.

막히던 것
- 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다.
  실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다
- 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은
  TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서
  두 문단이 서로 어긋나 있었다
- A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다

검사가 실패할 수 없던 자리
- B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도
  통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다
- k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다

certbot
- renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다.
  본문의 renew failure(s) 로만 판정할 수 있다
- --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을
  가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다
- 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다.
  certbot 2.1.0 에는 --run-deploy-hooks 도 없다
- 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1,
  notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754

독자가 칠 수 있는 형태로
- 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다
- 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로
  쳐서 문서가 적은 값과 같은지 확인했다
- 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다
- 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다

관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건

남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야
해서 안 했다(unknown).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:38:12 +09:00

47 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
7447ccbf-1800-43a4-a9d2-8ac774965c4b SETUP reproduce-b6-key-rotation 서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다 where-application-state-lives 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/7447ccbf-1800-43a4-a9d2-8ac774965c4b/edit
name version
curl 8.5.0
final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-6
cdac9b8178391311d8eca1ebc6cac15bb62d79af

서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다

realm 에 RSA 서명 키를 하나 더해 겹치는 구간을 만들고, 옛 공급자를 지운 뒤 두 토큰을 같은 두 줄로 다시 치는 절차다. 지운 키는 개인키와 함께 사라져 돌아오지 않으므로 실험대 전용 realm 에서만 한다. 약 15분.

관계

  • 볼륨 없는 영속화와 유예 없는 키 회전 이 절차가 만드는 옛 401 · 새 200 을 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다.
  • 주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다 여기서 -q 필터가 오류도 종료코드도 없이 빈 결과를 주는 대목이 그 아홉 건 중 하나다.
  • 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다 이 절차는 주입 검증에서 old 200 을 보기 전에는 관찰로 넘어가지 않는다. 그것을 안 보고 지우면 뒤에 나온 401 의 원인을 못 가른다.
  • cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다 같은 회전을 식별자가 없는 쪽에서 치는 편이다. 여기서 kid 가 겹침을 가능하게 하는 것을 보고 나면 그쪽에서 겹침이 왜 불가능한지가 한 줄로 끝난다.

본문

읽기 전에 — 어디서 치는가

명령은 전부 [lab host] 에서 친다. Keycloak 이미지에는 curlwget 도 없어서(exit 127) 파드 안에서 HTTP 요청을 보낼 수 없다. JWKS(JSON Web Key Set, 서버가 공개키를 싣는 목록)와 토큰은 호스트에서 공개 이름으로 치고, kcadm.shkubectl exec 로 감싸 파드 안에서 돌린다.

원 가이드는 이 명령들을 kc-lab-1 에서 치라고 적었다. 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 sudo 없는 kubectlpermission denied 로 막힌다 — kubeconfig 는 lab host 의 ~/.kube/config 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 kubectl 블록의 기계 이름을 [lab host] 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.

터미널은 하나면 된다. 붙잡아 두어야 하는 셸이 없고, 대신 OLDNEW 두 변수를 끝까지 들고 가므로 중간에 터미널을 닫지 않는다.

무엇
네임스페이스 keycloak-lab · 리소스 서버는 header-lab
realm keycloak-patterns — 클라이언트 bff-confidential, 사용자 labuser
주입 수단 kcadm.sh create componentspriority 가 더 높은 RSA 공급자를 하나 더 만든다
판정하는 쪽 리소스 서버 echo. 이 절차의 401 과 200 은 전부 그 앱이 낸다
시간 제약 access token 수명 60초. 토큰을 받고 1분 안에 그 토큰으로 친다
전 구간 약 15분. 주입 검증까지는 아무것도 안 깨진다
도구 jq 가 이 실험대에 없다. JSON 은 trgrep 으로 자른다

공개 이름은 랩 안에서 안 풀린다. auth.hyeonworks.com 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 100.83.212.4 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 curl000 을 낸다(2026-09-17, 랩 호스트와 kc-lab-1 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 curl 에는 --resolve <이름>:443:192.168.122.10 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 200 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 --resolve 가 필요 없다.

이 실험이 가르는 것

암호화 키를 어디에 두고 어떻게 교체하며, 교체하는 동안 옛 키로 저장된 값을 어떻게 읽는가. B층이 들고 온 이 물음이 두 갈래로 갈린다.

어느 키인가 지금 상태
토큰 저장소의 암호화 키 존재하지 않는다. B-2 에서 bytea 안이 JWT 문자열 그대로였다
토큰 서명 키 (Keycloak realm) 존재하고 회전할 수 있다 — 이 절차가 잰다

앞의 것이 없으므로 교체할 것도 없다. 그래서 이 절차는 뒤의 것만 치고, 거기서 본 모양이 나중에 앞의 것을 설계할 때 쓰인다.

원래 실행은 예측이 빗나간 실험이었다. 리소스 서버가 JWKS 를 캐시하니 옛 키를 지워도 한동안은 통할 것이라고 적어 두었는데, 제거 직후 바로 401 이 나왔다. 「교체」라는 한 단어가 성질이 정반대인 두 조작을 가리킨다.

   키 추가   →  무중단.  JWKS 에 옛 키와 새 키가 함께 남는다
   키 제거   →  ★ 즉시 파괴적.  옛 키로 서명된 토큰이 곧바로 401

절차를 끝까지 밟으면 RS256 키 수가 1 → 2 → 1 로 움직이는 것, 겹치는 구간에서 옛 토큰과 새 토큰이 둘 다 200 인 것, 제거 뒤에 옛 토큰만 401 이 되는 것, 리소스 서버를 재시작해 캐시를 비워도 그 401 이 그대로인 것을 자기 화면에서 보게 된다.

전제와 되돌리기

  • 05-keycloak 이 끝나 있고 realm keycloak-patterns 에 클라이언트 bff-confidential 과 사용자 labuser 가 있다.
  • B-0 이 끝나 BFF 가 떠 있다.
  • 리소스 서버(echo, 네임스페이스 header-lab)가 떠 있다. 이 절차의 401 과 200 은 전부 그 앱이 판정한다.
  • echo 가 발행자에 닿아야 한다. 그 앱은 SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI…JWK_SET_URIhttps://auth.hyeonworks.com/… 로 들고 있어서, 인증서 단계를 안 끝낸 실험대에서는 JWKS 를 못 받아 모든 토큰을 401 로 떨어뜨린다. 2026-09-17 에 그 상태에서 재 보니 이랬다(observed).
echo 가 발행자에 닿나      issuer=000  (curl exit 7)
유효한 토큰으로 /api/echo  401
토큰 없이 /api/echo        200

401200 이 뒤집혀 보이지만 키 회전과는 무관하다 — 검증기가 공개키를 못 구해 전부 거절했고, 보호되지 않은 경로만 통과했다. 이 편의 판정은 TLS 가 서 있어야 성립한다.

이건 되돌릴 수 없는 실험이다. 지우는 것은 서명 키 공급자이고 그 안의 개인키가 함께 사라진다. 같은 이름으로 공급자를 다시 만들어도 새 키 쌍이 생기고 kid 가 달라지므로, 옛 키로 서명된 토큰은 영구히 검증되지 않는다. 실험대에서만 한다.

되돌릴 수 있는 것은 주입 하나다. 방금 만든 공급자를 지우면 원래대로 돌아간다. id 는 주입이 화면에 찍어 주는 값이다 — 원래 실행에서는 7902af43-a0cc-4ebd-ad25-04d563854d16 이었다. 아래 블록의 {{NEW_PROVIDER_ID}} 를 8 절 출력이 찍어 준 자기 id 로 바꿔야 지워진다. 8 절을 치기 전에는 지울 공급자가 없다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete components/{{NEW_PROVIDER_ID}} -r keycloak-patterns

주입 전에 같은 명령으로 먼저 본다

제거 후에 볼 것을 제거 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.

kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가

1. kcadm 세션을 파드 안에 만든다

목적 — 뒤의 모든 kcadm.sh 명령이 관리 API 로 인증되게 한다.

행동 — 관리자 자격증명으로 로그인하고, 값이 넘어갔는지는 길이로만 본다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master --user admin \
  --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
                -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

예상 결과 — ① 은 아무것도 안 나오면 성공이다. 실패하면 한 줄짜리 오류가 뜬다. ② 는 0 이 아닌 수를 낸다.

왜 필요한가kcadm.sh 는 파드 안 파일에 세션을 저장한다. 파드가 재시작되면 그 파일이 사라지고 그다음 모든 명령이 401 로 떨어지므로 맨 앞에서 한 번 해 둔다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다.

문제가 생기면 — 중간에 kcadm 이 전부 Unauthorized 로 바뀌면 파드가 재시작된 것이다. ① 을 다시 친다.

2. 키 공급자 목록을 통째로 받는다

무엇을 보는가 — 이 realm 에 어떤 키 공급자가 있고 각각의 id 가 무엇인지.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId

어디를 보나 — JSON 배열이 여러 줄로 나온다. providerIdrsa-generated 인 항목이 서명 키 공급자이고 hmac-generated · aes-generated 등이 함께 나온다. "name" : "rsa-generated" 인 항목의 "id" 를 지금 적어 둔다. 관찰 절에서 지울 대상이다.

이 값이 뜻하는 것 — 가이드는 「키 공급자만 걸러 보자」는 아래 시도가 빈 결과를 준다고 적고 미검증으로 표시했다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider

2026-09-17 에 쳐 보니 결과가 나왔다(observed). 26.7.0 에서 이 질의는 공급자 넷을 그대로 돌려준다.

"name":"rsa-enc-generated"  "name":"hmac-generated-hs512"  "name":"aes-generated"  "name":"rsa-generated"

빈 결과를 봤다면 세션이 없어서일 수 있다. 같은 날 세션이 끊긴 상태로 먼저 쳤을 때는 이렇게 끝났다(observed).

No server specified. Use --server, or 'kcadm.sh config credentials'.

어느 쪽이든 판정은 같다 — 빈 출력은 「없다」가 아니라 「이 명령으로는 안 보인다」일 수 있다. 그때는 --fields 로 전체를 받아 눈으로 고른다.

:::warning

kcadm 세션은 파드 안에 산다. /opt/keycloak/.keycloak/kcadm.config 에 놓이므로 rollout restart 나 이미지 교체로 파드가 갈리면 그 파일째 사라진다. 그러면 401 이 아니라 No server specified 로 끝나고, 앞 절에서 config credentials 를 이미 쳤어도 소용없다. 파드를 갈아 끼운 뒤에는 다시 친다(2026-09-17, observed).

:::

3. JWKS 원문을 한 번 통째로 본다

무엇을 보는가 — 어떤 필드가 실려 있는지. 다음부터 무엇으로 걸를지가 여기서 정해진다.

curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs

--resolve 가 붙은 까닭이다(2026-09-17, observed). 원 가이드는 이름만 쳤는데 auth.hyeonworks.com 은 호스트 자신의 tailnet 주소로 풀리고 호스트에는 80 도 443 도 듣는 것이 없다. 엣지 nginx 가 게스트로 옮겨 간 뒤로 그렇다.

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
000
Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server

tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿는다. 랩 호스트에서 쳐야 해서 --resolve 를 붙였고, 이 편의 [lab host] 블록 열두 줄에 전부 같은 것이 걸린다.

줄바꿈 없이 한 줄로 길게 나온다. 실측의 첫머리는 이렇다(observed, 01-before-rotation.txt).

{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"

그 뒤로 kty · alg · use · n · e 가 이어지고 다음 키가 온다. kid 마다 alg 가 따로 붙는다. 읽을 만하게 자를 때는 jq 가 없으므로 tr 로 쉼표를 줄바꿈으로 바꾼다.

curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

어디를 보나 — 실측은 이렇다(observed, 01-before-rotation.txt).

  JWKS kid 목록:
    {"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
    {"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"

이 값이 뜻하는 것kid 는 둘인데 같은 파일의 윗줄은 RS256 키가 하나라고 적는다(observed).

  JWKS 의 RS256 키 수: 1

세는 단위가 다르다. JWKS 에는 서명 키만 실리지 않는다. 이 realm 에서는 암호화용 키(RSA-OAEP 계열)가 함께 실려 있고 그것도 kid 를 갖는다. grep kid | wc -l 로 세면 서명 키 수를 과다 계산한다.

4. RS256 만 세는 두 형태를 알아 둔다

무엇을 보는가 — 알고리즘까지 보고 세는 방법. 아래 두 줄은 가이드가 미검증으로 표시했다(unknown).

JWKS 는 키 하나가 } 로 끝나므로 tr '}' 로 자르면 한 줄이 한 키가 된다.

curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr '}' '\n' | grep -c RS256

Keycloak 자신에게 묻는 쪽이 확실하고 그쪽이 1순위 도구다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get keys -r keycloak-patterns

어디를 보나 — 키마다 붙는 algorithmstatus 를 보고, RS256 이면서 ACTIVE 인 것이 지금 서명에 쓰이는 키다.

2026-09-17 에 둘 다 쳐 봤고 둘 다 돈다(observed). 원래 실행 기록에는 출력이 없어 미검증으로 두었던 것인데, 이제 값이 있다.

1
"kid" : "abfdb1a2-539c-4be6-b651-30e8a8e5c917"   "status" : "ACTIVE"   "algorithm" : "AES"
"kid" : "24d796a2-ca3c-477c-bf9f-c19f81e0e64c"   "status" : "ACTIVE"   "algorithm" : "HS512"
"kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"   "status" : "ACTIVE"   "algorithm" : "RS256"
"kid" : "5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI"   "status" : "ACTIVE"   "algorithm" : "RSA-OAEP"

① 이 내는 1 과 3단계의 kid 두 줄이 이제 맞아떨어진다. 키는 넷이고 그중 서명용 RS256 이 하나, 암호화용 RSA-OAEP 가 하나이며 JWKS 에는 그 둘만 실린다. AESHS512 는 JWKS 에 안 나온다.

5. 시험체가 될 옛 토큰을 하나 받아 둔다

목적 — 회전 전에 발급된 토큰을 확보한다. 이 토큰 하나가 이 절차의 시험체다.

행동 — 토큰 엔드포인트와 클라이언트 비밀을 변수에 담고 direct grant 로 받는다.

KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
CS=$(kubectl -n keycloak-lab get secret bff-secrets \
     -o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
OLD=$(curl -s -X POST --resolve auth.hyeonworks.com:443:192.168.122.10 "$KC" \
  -d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
  -d username=labuser -d password=labpass -d scope=openid \
  | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "${#OLD}자"

예상 결과 — 길이만 나온다. 원래 실행의 모양은 이렇다(observed).

2043자

왜 필요한가 — 변수 이름이 OLD 인 까닭은 회전이 끝난 뒤에도 이 값이 「옛 키로 서명된 토큰」으로 남아 있어야 하기 때문이다. 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다. 토큰 값도 클라이언트 비밀도 화면에 찍지 않고 길이만 본다.

문제가 생기면0자 가 나오면 토큰을 못 받았다. 변수에 담지 말고 같은 curl 을 그대로 쳐서 응답 본문을 읽는다.

6. 그 토큰이 어느 키로 서명됐는지 읽는다

무엇을 보는가 — JWT 의 첫 토막이 헤더이고 거기 kid 가 있다.

echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

어디를 보나 — 원래 실행의 모양은 이렇다(observed).

{"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}

콜론 양옆의 공백을 눈여겨본다. Keycloak 은 토큰 헤더"typ" : "JWT" 처럼 공백을 넣어 찍고, 페이로드는 "sid":"…" 처럼 붙여 찍는다. 그래서 페이로드에서 되던 sed 가 헤더에서는 빈손으로 돌아온다. 2026-09-17 에 같은 토큰 하나로 두 형태를 나란히 쳤다(observed).

헤더: {"alg":"RS256","typ" : "JWT","kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"}
'"kid":"' 로 뽑으면   : []
'"kid" *: *"' 로 뽑으면: [HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg]

헤더에서 값을 뽑을 때는 공백을 허용한다.

echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null \
  | sed -n 's/.*"kid" *: *"\([^"]*\)".*/\1/p'

증거 파일에는 이렇게 남아 있다(observed, 01-before-rotation.txt).

  발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM

이 값이 뜻하는 것kid 는 key ID 이고, 서명한 쪽이 어느 키를 썼는지 토큰 헤더에 적어 준다. 검증하는 쪽은 JWKS 에서 그 kid 를 찾아 공개키를 얻는다. kid 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고, 키가 바뀌는 순간 옛 토큰이 전부 죽는다. 겹치는 구간을 가능하게 하는 것이 이 kid 다. 여기서 본 값이 3 절의 목록에 있는지 대조한다. base64 패딩 때문에 끝이 깨져 보일 수 있고(2>/dev/null 이 그 불평을 지운다) 헤더는 짧아서 대개 온전히 보인다.

7. 그 토큰이 지금 통하는지 본다

무엇을 보는가 — 대조군. 이 확인을 건너뛰면 뒤의 401 이 아무 의미가 없다.

curl -s -i -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
  https://app1.hyeonworks.com/api/me

200 이면 subject 같은 클레임이 돌아오고, 401 이면 WWW-Authenticate 헤더에 이유가 붙는다. 이 헤더를 한 번 봐 두면 뒤에서 401 이 났을 때 왜인지 물을 근거가 생긴다. 여러 번 비교할 때부터는 코드만 뽑는다.

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
    https://app1.hyeonworks.com/api/me

어디를 보나 — 실측은 이렇다(observed, 01-before-rotation.txt).

=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
  /api/me  HTTP 200

이 값이 뜻하는 것 — 회전 전에는 통한다. 원래 실행은 클러스터 안에서 http://echo.header-lab.svc:8081/api/me 를 쳤고, 이 절차가 공개 이름을 쓰는 까닭은 kc-lab-1 에서 클러스터 DNS 가 안 풀리기 때문이다. app1.hyeonworks.com/api 는 Ingress 가 같은 echo 로 보내므로 도달하는 앱은 같다. 그리고 access token 은 60초짜리다(이 realm 은 accessTokenLifespan=60). 1분을 넘기면 회전과 무관하게 401 이 나온다.

주입

8. 우선순위가 더 높은 RSA 공급자를 추가한다

목적 — 발급은 새 키로 가고 검증은 옛 키와 새 키를 둘 다 받는 상태를 만든다.

Keycloak 의 키 회전은 바꾸기가 아니라 더 높은 우선순위로 추가하기다. 기존 공급자는 그대로 두고 priority 가 더 큰 공급자를 하나 더 만든다.

   t0   키 A 만 있다.  발급: A,  검증: A
   t1   키 B 추가.     발급: B,  검증: A + B      ← 겹치는 구간
   t2   키 A 제거.     발급: B,  검증: B

행동 — 공급자를 만들고 시각을 남긴다.

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"]'
date '+%H:%M:%S 추가'

예상 결과 — 실측은 이렇다(observed, 02-rotation.txt).

=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'

돌아온 id 를 적어 둔다. 전제 절의 되돌리기가 그 값을 쓴다.

왜 필요한가config.priority 가 기존 공급자보다 커야 발급이 새 키로 간다. 기본값은 100 이고 여기서는 200 을 줬다. 낮게 주면 새 키는 만들어지지만 발급에 쓰이지 않아 주입 검증에서 kid 가 안 바뀐다. config.* 값이 대괄호로 감싼 배열인 것에도 주의한다 — -s config.priority=200 처럼 쓰면 형이 안 맞는다. Keycloak 컴포넌트 설정은 값이 전부 문자열 목록이다.

문제가 생기면 — 생성이 거절되면 config.* 의 대괄호부터 본다.

주입 검증

결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다. 주입 전과 똑같은 명령을 다시 친다.

9. JWKS 에 옛 키가 남아 있는가

curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

실측은 이렇다(observed, 02-rotation.txt).

=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
  RS256 키 수: 2
  kid 목록:
    {"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
    {"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
    {"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"

kid(OY-caYDN…)가 목록에서 빠지지 않았다. 새 것이 하나 늘었고 아무것도 사라지지 않았다. JWKS 는 지금 검증에 쓸 수 있는 키 전부를 싣는 목록이고, 추가는 그 목록을 늘린다.

10. 새 토큰은 어느 키로 서명되는가

OLD 은 건드리지 않는다.

NEW=$(curl -s -X POST "$KC" \
  -d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
  -d username=labuser -d password=labpass -d scope=openid \
  | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

실측은 이렇다(observed, 02-rotation.txt).

=== [5] 새 토큰은 어느 키로 서명되는가 ===
  새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84

kid 가 우선순위 200 짜리 새 키로 바뀌었다. 발급은 우선순위가 가장 높은 키로 간다. 여기서 kid 가 안 바뀌었다면 priority 를 낮게 준 것이다.

11. 둘 다 통해야 겹치는 구간이 무중단이다

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
    https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
  -H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \
    https://app1.hyeonworks.com/api/me

실측은 이렇다(observed, 02-rotation.txt).

=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
  옛 토큰  /api/me  HTTP 200
  새 토큰  /api/me  HTTP 200

둘 다 200 이므로 키 추가는 무중단이다. 새 토큰은 새 키로 서명되고 옛 토큰은 JWKS 에 아직 있는 옛 키로 검증되며 사용자는 아무것도 못 느낀다.

여기서 old 가 401 이면 둘 중 하나다. 토큰이 만료됐거나(60초), 추가 말고 다른 것을 건드렸다. 가르는 법은 옛 토큰의 exp 를 보는 것이고 JWT 의 가운데 토막이 클레임이다.

echo "$OLD" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
date +%s

exp 가 지금보다 작으면 만료다. 다시 받아야 하는데, 순서 그대로 5 절만 다시 치면 8 절이 만든 rsa-rotated 가 이미 priority 200 이라 새로 받은 토큰도 새 키로 서명된다. 8 절이 화면에 찍어 준 id 로 그 공급자부터 지우고(전제 절의 되돌리기 한 줄), 5 절로 토큰을 받고, 8 절을 다시 쳐서 추가한 뒤 11 절로 온다.

관찰

여기서부터 되돌릴 수 없다. 계속하기 전에 셋을 확인한다 — 이 realm 이 실험대 전용인가, 지금 살아 있는 세션 중에 잃으면 곤란한 것이 있는가, 11 절의 old 200 을 실제로 봤는가. 마지막 것을 안 봤다면 401 이 나와도 원인을 못 가른다.

넷째로 $OLD 에 시간이 얼마나 남았는지 본다. access token 이 60초짜리라, 12 절에서 목록을 받아 눈으로 id 를 고르고 13 절에서 그 id 를 손으로 옮겨 적는 동안 만료된다. 그러면 15 절의 old 401 이 키 제거 때문인지 만료 때문인지 안 갈린다 — 이 절차의 결론이 바로 그 401 이라, 여기서 못 가르면 아무것도 못 잰다. 11 절의 만료 확인 두 줄을 지금 한 번 쳐서 expdate +%s 의 차이를 보고, 12·13 절을 칠 만큼 안 남았으면 11 절이 안내한 대로 $OLD 를 다시 받고 온다.

12. 지울 대상을 정확히 고른다

무엇을 보는가 — 남길 것과 지울 것의 id. -q 는 여전히 안 먹는다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId

목록이 길면 그 항목 둘레만 잘라 본다. "id""name" 보다 위에 나온다.

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId \
  | grep -B2 '"name" : "rsa-generated"'

어디를 보나 — 남길 것이 "name" : "rsa-rotated" 이고 지울 것이 "name" : "rsa-generated" 다.

이 값이 뜻하는 것 — 둘을 바꿔 지우면 실험이 뒤집힌다. 원래 실행에서 옛 공급자의 id 는 980ee9b7… 로 시작했고 그것이 OY-caYDN… 키를 갖고 있었다. 환경마다 id 가 다르고 증거에 남은 것도 앞 8자뿐이니 전체 id 는 위 출력에서 그대로 옮겨 온다.

13. 옛 공급자를 지운다

목적 — 옛 서명 키를 JWKS 에서 없앤다.

행동 — 위 출력의 id 를 변수에 옮기고 지운다. 첫 줄의 {{OLD_PROVIDER_ID}} 를 12 절 출력에서 읽은 자기 id 로 바꾼 뒤에 친다. 그대로 붙여넣으면 셸이 멈춘다{{ }} 는 셸 문법이 아니라서 눈앞에서 실패한다. 전에는 첫 줄에 원래 실행의 값이 그대로 박혀 있었는데, 그것은 유효한 대입이라 조용히 돌았다: OLDID 에 그 문자열이 들어가고 아래 delete 가 실제로 나가 없는 컴포넌트를 지우라는 요청이 되고, 옛 공급자는 살아 있고, 15 절이 옛 200 을 내 결론이 뒤집힌다.

OLDID={{OLD_PROVIDER_ID}}        # ← 12 절 출력의 id 를 그대로 옮긴다. 환경마다 다르다

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete components/"$OLDID" -r keycloak-patterns
date '+%H:%M:%S 제거'

예상 결과 — 조용히 끝나면 성공이다. 증거 파일에는 이렇게 남아 있다(observed, 03-old-key-removed.txt).

=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
  제거 완료

왜 필요한가 — 시각을 적어 두면 뒤에 나온 401 을 이 조작에 귀속할 수 있다. 그리고 지워진 것은 공급자이므로 그 안의 개인키도 함께 사라진다.

문제가 생기면 — 지운 뒤 새 토큰까지 401 이면 새 공급자를 지운 것이다. 14 절과 15 절을 먼저 치고 kid 를 대조한다.

14. JWKS 에서 사라졌는지 본다

curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

실측은 이렇다(observed, 03-old-key-removed.txt).

=== [8] JWKS 에서 사라졌는가 ===
  RS256 키 수: 1
    {"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
    {"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"

OY-caYDN… 이 없고 RS256 은 다시 하나다. gokjn0… 은 처음부터 끝까지 목록에 있는데, 서명 키가 아니라 암호화 키이기 때문이다.

15. 두 토큰을 같은 두 줄로 다시 친다

치기 전에 $OLD 가 아직 안 죽었는지 본다. 11 절의 만료 확인 두 줄을 그대로 다시 친다. expdate +%s 보다 작으면 아래에서 나올 old 401 은 키 제거가 아니라 만료이고, 두 401 은 화면에서 똑같이 보인다.

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
    https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
  -H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \
    https://app1.hyeonworks.com/api/me

실측은 이렇다(observed, 03-old-key-removed.txt).

=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
  옛 토큰  /api/me  HTTP 401  (캐시가 살아 있으면 아직 통할 수 있다)
  새 토큰  /api/me  HTTP 200

제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있다.

★ 2026-09-17 에 다시 재 보니 그 401 은 절반만 맞았다(observed). 같은 토큰으로 여덟 번 연속 쳤더니 이렇게 나왔다.

401 200 401 200 401 200 401 200

echo 가 replica 둘이고 JWKS 캐시가 인스턴스마다 따로이기 때문이다. 한쪽은 목록을 새로 받아 옛 키를 잃었고(401) 다른 쪽은 아직 들고 있다(200). Traefik 이 번갈아 보내므로 어느 쪽이 답하느냐에 따라 결과가 갈린다. 파드가 둘 다 1/1 Running 인 것은 같은 순간에 확인했다.

한 번만 쳐서는 이것이 안 보인다. 처음 두 번을 쳤을 때 이렇게 갈렸다.

제거 6초 뒤 처음 친 것   old 200     ← 캐시가 살아 있는 replica
제거 6초 뒤 다시 친 것   old 401     ← 목록을 새로 받은 replica

그래서 이 절의 판정은 「즉시 401」이 아니라 「인스턴스마다 다르다」다. 운영에서 더 나쁜 형태다 — 옛 토큰을 쥔 사용자가 요청마다 성공과 실패를 오가고, 로그에는 401 이 절반만 남아 재현이 안 되는 장애로 보인다. 원 실행이 「유예가 없다」로 닫은 것은 한 번 친 값이 마침 새로 받은 replica 쪽이었기 때문으로 보인다(inferred).

replica 를 1 로 줄이면 이 흔들림이 사라진다. 무엇을 재려는지에 따라 고른다 — 「제거가 반영되는가」를 보려면 1 로, 「운영에서 무엇이 보이는가」를 보려면 2 로 둔다.

아래 16 절이 이것을 가르는 단계인데, 재시작이 34초 걸려 60초짜리 토큰으로는 전후를 같은 토큰으로 못 견준다(observed). 재시작 뒤의 old 401 은 캐시가 비워져서인지 토큰이 만료돼서인지 갈리지 않는다. 가르려면 accessTokenLifespan 을 늘리거나, 위처럼 재시작 없이 연속으로 쳐서 두 replica 의 답이 갈리는 것을 보는 편이 빠르다.

$OLD 가 만료된 뒤였다면 이 실행은 15·16 절의 판정을 못 낸다. 옛 키는 13 절에서 개인키와 함께 사라져 옛 키로 서명된 토큰을 새로 만들 방법이 없다. 그때는 401 을 키 제거에 귀속하지 말고, 실험대를 5 절부터 다시 밟되 8 절에서 15 절까지를 토큰 수명 안에 끝낸다. 원래 실행이 그 구간을 얼마 만에 끝냈는지는 원본 가이드에 없다(unknown).

16. 리소스 서버를 재시작해 캐시를 비운다

목적 — 401 이 캐시 상태 때문인지 가른다.

kubectl -n header-lab rollout restart deploy/echo
kubectl -n header-lab rollout status deploy/echo --timeout=180s

그다음 15 절의 두 줄을 다시 친다. 실측은 이렇다(observed, 03-old-key-removed.txt).

=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
  옛 토큰  /api/me  HTTP 401
  새 토큰  /api/me  HTTP 200

왜 필요한가 — 재시작 전과 후가 같으므로 401 은 캐시 상태와 무관하고 캐시는 유예를 주지 않았다. Spring 의 NimbusJwtDecoder 는 모르는 kid 를 만나면 JWKS 를 다시 가져온다. 캐시는 이미 아는 키를 다시 안 받으려는 장치이지 옛 키를 붙잡아 두는 장치가 아니다.

   옛 토큰 도착
      │
      ├─▶ kid = OY-caYDN…  →  캐시에 없다
      │                          │
      │                          └─▶ JWKS 를 다시 가져온다 (여기서 오히려 빨리 갱신된다)
      │
      └─▶ 새로 받은 JWKS 에도 없다  →  401

캐시가 오히려 제거를 빨리 반영시킨다. 유예는 캐시로 만드는 것이 아니라 옛 키를 JWKS 에 남겨 두는 기간으로 만든다.

문제가 생기면 — 재시작 뒤 옛 토큰이 200 이면 지운 것이 그 토큰의 키가 아니다. 12 절의 목록과 6 절의 kid 를 대조한다.

겹치는 구간은 얼마나 길어야 하나

이 절차는 그 길이를 재지 않았다. 추가와 제거가 연달아 일어났고 전 구간이 약 15분이다. 아래 값은 realm 설정에서 따라 나온 추론이다.

이 실험대에서 수명
access token 60초
refresh token 1800초 (30분)
필요한 겹침 최소 30분 — 앞 두 값에서 따라 나온 추론이고 측정하지 않았다

겹침의 최소 길이는 옛 키로 서명된 것 중 가장 오래 사는 것의 수명과 같다. 실제로 재려면 추가와 제거 사이를 30분 이상 벌리고, 그 사이에 받은 refresh token 으로 제거 뒤에 갱신을 시도한다. 이 절차에는 그 단계가 없다.

수명 세 값은 realm 설정이므로 직접 볼 수 있다. 가이드가 이 줄을 미검증으로 표시했다(unknown).

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan

2026-09-17 에 쳤더니 두 값만 돌아왔다(observed) — accessTokenLifespan60, ssoSessionIdleTimeout1800 이고 ssoSessionMaxLifespan 은 이 realm 이 안 내놓는다. 겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다.

   쓰기: 새 key 하나로만
   읽기: 새 key + 옛 key(들)      ← key 에도 식별자가 필요하다
   제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤

저장된 값에 kid 에 해당하는 표시가 없으면 회전이 불가능하다. 그것이 없을 때 무슨 일이 나는지는 B-7 이 잰다.

복구와 원상복구 확인표

이 절차에는 원상복구가 없다. 지운 키 공급자는 개인키와 함께 사라졌고, 같은 이름으로 다시 만들면 새 키 쌍이 생기고 kid 가 다르므로 옛 토큰은 그래도 401 이다. 정상 상태는 새 키 하나만 남은 상태이고, 실험 전과 다르지만 깨진 상태가 아니다.

남은 것 어떻게
rsa-rotated 공급자 그냥 둔다. 지금 유일한 RS256 서명 키다 지우면 realm 이 서명할 키를 잃는다
셸 변수 OLD NEW CS 터미널을 닫으면 사라진다 unset OLD NEW CS
실험 중 발급한 토큰 60초 뒤 만료된다 별도 조치 없음

이름이 거슬리면 새 공급자를 하나 더 만들고 rsa-rotated 를 지운다. 다만 그것 역시 또 한 번의 회전이고 또 하나의 새 키다.

항목 명령 돌아왔을 때
서명 키 curl -s …/protocol/openid-connect/certs | tr ',' '\n' | grep kid RS256 이 하나
새 토큰 토큰 발급 + /api/me 200
리소스 서버 kubectl -n header-lab get pods echo1/1 Running
Keycloak kubectl -n keycloak-lab get pods 둘 다 1/1 Running
공급자 목록 kcadm get components --fields id,name,providerId rsa-generated 가 없고 rsa-rotated 가 있다

막히면

원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.

증상 원인 확인
kcadm get components -q type=... 가 빈 결과 -q 필터가 안 먹는다. 오류도 없다 --fields id,name,providerId 로 전체를 받는다
kcadm 이 전부 401/Unauthorized 파드가 재시작되어 kcadm 세션이 사라졌다 config credentials 를 다시
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 JWKS 와 토큰은 kc-lab-1 호스트에서 친다
jq: command not found 이 실험대에는 jq 가 없다 tr ',' '\n' | grep 로 자른다
kid 를 세니 2개인데 문서는 1개라고 한다 RS256 이 아닌 암호화 키가 섞여 있다 tr '}' '\n' | grep -c RS256 또는 kcadm get keys
공급자를 추가했는데 새 토큰의 kid 가 그대로 config.priority 가 기존보다 낮다 값이 ["200"] 처럼 배열인지
추가만 했는데 옛 토큰이 401 추가가 아니라 토큰이 만료됐다(60초) 클레임의 expdate +%s 비교
제거했는데 새 토큰이 401 지운 것이 새 공급자다 kid 를 다시 확인하고 남은 공급자 목록을 본다
제거했는데 옛 토큰이 200 지운 것이 그 토큰의 키가 아니다 토큰 헤더의 kid 와 지운 공급자의 키를 대조
「캐시 때문일 것」이라 재시작을 기다린다 캐시는 유예를 주지 않는다 재시작 전후가 같다
지운 키를 되살리려 한다 되살릴 수 없다. 같은 이름과 같은 키는 다르다 새 키 하나만 남은 상태가 정상이다

이 실험대에서 아직 못 밟은 단계

2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. 못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.

  • 밟았다 — 키 공급자 추가와 삭제, 그리고 그 둘이 JWKS 와 토큰 kid 를 어떻게 바꾸는지 전부. 8·9·10·12·13·14 절이 적어 둔 대로 나왔다(observed). 공급자를 추가하니 RS256 이 1 에서 2 로 늘고 새 토큰의 kidpriority 200 짜리 새 키로 바뀌었으며, 옛 공급자를 지우니 RS256 이 다시 1 이 되고 그 kid 가 목록에서 사라졌다.
  • 끝까지 밟았다(2026-09-17). TLS 가 선 뒤 echo 가 JWKS 를 받게 되어 7 절의 대조군 old 200 이 나왔고, 8~15 절을 토큰 수명 안(6초)에 끝냈다. 결과는 11 절까지 문서 그대로(추가는 무중단, old 200 · new 200)이고 15 절에서 갈렸다 — 위 ★ 를 본다.
  • 막는 것https://auth.hyeonworks.com 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)가 있어야 한다. 밖에서 닿는 길은 열렸다 — 호스트의 libvirt guest_input 구멍과 유닛의 ExecStartPost 가 2026-09-17 에 들어갔고 http 는 밖에서 200 이다(observed).

11·15 절이 만료를 가르라고 준 두 줄은 이 실험대에서 틀린 답을 낸다(2026-09-17, observed). exp 는 Keycloak 이 게스트 시계로 찍고 date +%s 는 랩 호스트 시계로 찍는데, 그 둘이 93초 어긋나 있다.

date +%s
timedatectl show -p NTP -p NTPSynchronized
lab host     1789629566   NTP=no    NTPSynchronized=no
kc-lab-1     1789629472   NTP=yes   NTPSynchronized=yes
kc-lab-2     1789629473   NTP=yes   NTPSynchronized=yes
kc-lab-edge  1789629473

게스트 셋은 서로 맞고 랩 호스트만 93초 앞선다. 그래서 방금 받은 60초짜리 토큰도 랩 호스트에서 exp 를 재면 이미 33초 전에 만료된 것으로 읽힌다. D-4 와 D-4a 가 같은 호스트에서 NTPSynchronized=no+106.1 을 이미 재 두었는데, 이 편은 그것을 모르는 채로 exp 비교를 시킨다. 가르려면 두 값을 같은 기계에서 뽑는다 — kubectl -n keycloak-lab exec keycloak-0 -- date +%s 로 Keycloak 쪽 시각을 받아 견준다.

  • 막는 것https://auth.hyeonworks.com 이 서지 않는다. 남은 것은 인증서 하나다(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt guest_input 구멍과 유닛의 ExecStartPost 가 들어가면서 열렸고(http 가 밖에서 200), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
  • realm 의 수명 세 값은 이제 실측이 있다(observed). 위에서 미검증으로 표시한 줄을 쳐 보니 accessTokenLifespan60, ssoSessionIdleTimeout1800 이다. 세 번째 값 ssoSessionMaxLifespan 은 이 realm 이 안 내놓는다.
  • 그때까지 이 편의 (observed) 2026-09-17 값은 위 「밟았다」 범위뿐이다. 나머지는 원래 실행의 값이다.

무엇이 관측이고 무엇이 아닌가

이 절차의 숫자는 2026-09-04 14:3014:32 KST 에 돈 한 번의 실행에서 나왔다(observed). 해설 문서 머리의 15:5016:00 KST 는 문서를 쓴 시각이고 증거 파일의 mtime 이 앞의 값이라, 실측으로 인용하는 것은 뒤쪽이라고 가이드가 적는다.

  • (observed) 회전 전 JWKS 의 kid 두 개(gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4 · OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM)와 RS256 키 수 1, 발급 토큰의 kidOY-caYDN… 인 것, 그 토큰의 /api/me HTTP 200, 추가한 공급자의 id 7902af43-a0cc-4ebd-ad25-04d563854d16, 회전 후 RS256 키 수 2 와 늘어난 kid 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84, 새 토큰의 kid 가 그것인 것, 겹치는 구간의 옛 200 · 새 200, 제거 후 RS256 키 수 1 과 옛 401 · 새 200, echo 를 재시작한 뒤에도 옛 401 · 새 200 인 것.
  • 비밀은 길이와 존재만 적었다. 클라이언트 비밀은 CS 변수에 명령 치환으로만 넘겨 화면에 찍지 않고, admin 비밀번호도 wc -c 로 길이만 본다. 토큰은 2043자 라는 길이만 옮겼고 값은 증거 파일에 있다. kid 와 공급자 id 는 공개 식별자라 그대로 적었다.
  • (unknown) -q type=org.keycloak.keys.KeyProvider 로 거르는 줄(조용히 빈 결과를 준다), tr '}' '\n' | grep -c RS256 로 RS256 만 세는 줄, kcadm get keys 로 알고리즘과 상태를 보는 줄, realm 의 수명 세 값을 한 번에 받는 줄. 가이드가 전부 미검증으로 표시했고 원래 실행 기록에 이 명령들의 출력이 없다.
  • 판 번호는 이 편의 출력에 하나도 안 찍혔다. B층 아홉 편 가운데 판 번호가 남은 것은 다섯 편이고 B-6 은 거기 없다(observed). curl 8.5.0 은 같은 실험대의 B-4 가 echo 앱에서 되돌려받은 user-agent 이지 이 편이 잰 값이 아니다(inferred).
  • 추론이지 측정이 아닌 것 — 「겹침은 최소 30분」은 access token 60초와 refresh token 1800초라는 설정에서 따라 나온 값이다. 겹침을 실제로 30분 유지하며 그 사이에 발급된 refresh token 이 제거 뒤에 어떻게 되는지는 측정하지 않았다.