Files
keycloak-pattern/docs/guides/experiments/b7-cookie-secret-rotation.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

32 KiB
Raw Blame History

B-7 재현 가이드 — cookie secret 을 갈아치우고, 로그인해 있던 사람에게 무슨 일이 나는지 본다

해설 문서: docs/experiment-b7-cookie-secret-rotation.md · 증거 원문: docs/evidence/b7-cookie-secret/

이 가이드가 끝나면

당신 터미널과 브라우저에서 이것들을 직접 본다.

보게 되는 것 어디서
replica 두 개가 상태를 안 나누고도 로그인이 되는 것 oauth2-proxy 로그 (시작한 replica ≠ 콜백 받은 replica)
큰 쿠키가 프록시를 못 넘어 502 가 되는 것, 그리고 계층을 갈라 원인을 좁히는 법 Traefik 직접 vs nginx
--cookie-secret단수라는 것 oauth2-proxy --help
옛 쿠키가 session ticket cookie failed validation 로 죽는 것 프록시 로그
로그인 화면 없이 조용히 재로그인되는 것 브라우저
★ 프록시가 지우지 못한 서버 세션이 Redis 에 남는 것 redis-cli --scan

전제

  • 05-keycloak 가 끝나 있고, realm keycloak-patterns 에 클라이언트 oauth2-proxy 와 사용자 labuser(비밀번호 labpass)가 있다.
  • Redis 가 redis.keycloak-lab.svc:6379 로 떠 있다.
  • 브라우저가 필요하다. 쿠키가 HttpOnly 이고 OIDC 흐름을 끝까지 걸어야 세션이 생긴다. curl 로 완주하려던 시도는 실패했다 — 「막히면」 표에 그 기록이 있다.
  • 명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다.
  • 이 실험대에는 jqyamllint 도 없다.

주의 — 이건 남의 도메인을 빌리고, 남의 세션을 끊는 실험이다

두 가지를 건드린다.

  1. app2.hyeonworks.com 은 평소 observability 네임스페이스의 Grafana 로 간다. 인증서가 auth·app1·app2 세 이름만 덮고 있어서 네 번째 이름을 못 만든다. 그래서 Grafana 의 Ingress 를 잠시 내리고 빌린다. 반드시 되돌린다5-3 이 그 절차다. 백업을 뜨는 것이 1-1 의 첫 명령인 이유다.
  2. secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다. 실험대에서만 한다.

전 구간 약 20분이다. 중간에 그만두려면 5. 복구 를 위에서부터 친다.

표시 규약

표시
실측 2026-09-04 14:3514:42 KST 수집 기록의 출력 원문. 증거 파일에 그대로 있다
형태 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다
미검증 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다

시계에 주의한다. kubectl 로 보는 시각은 KST 인데 oauth2-proxy 가 찍는 로그 타임스탬프는 UTC 다. 증거의 로그가 [2026/09/04 05:41:46] 인 것과 수집 시각이 14:3514:42 KST 인 것은 같은 시간대의 같은 순간이다(KST = UTC+9). 이 어긋남을 모르고 로그를 뒤지면 9시간 전을 뒤지게 된다.

파드 이름·Redis 키·쿠키 값은 당신 환경에서 다르다. 이 문서는 자리표시자(<...>)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.


0. 왜 이 실험을 하는가

Q1 의 미지수 7 은 이렇게 물었다.

"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."

B-6 에서 Keycloak 은 두 키를 동시에 들고 무중단으로 회전했다. kid 가 있어서 「읽기는 여러 키, 쓰기는 하나」가 가능했기 때문이다.

예측
B-6 의 모양대로라면 oauth2-proxy 도 겹침 구간을 만들 수 있을 것
실측 ★ 없다. --cookie-secret 은 단수이고 쿠키에 key 식별자가 없다

그리고 예측하지 않았던 것이 하나 더 나온다 — 사용자는 아무것도 못 느끼는데 서버 쪽에 지워지지 않는 세션이 남는다. 그 「지우지 못한다」를 이어서 재는 것이 B-7a 다.

핵심은 상태를 어디에 두었는가다.

   BFF          인가 요청을 서버 메모리(HttpSession)에 둔다  →  replica 를 넘으면 실패
   oauth2-proxy 인가 요청을 쿠키에 두고 secret 으로 봉인한다  →  replica 를 넘어도 성공
                                                                  대신 secret 이 단일 지점

공유할 상태가 없으면 공유 문제도 없다. 대신 secret 하나가 전부를 쥔다.


1. 기준선 — 아무것도 바꾸기 전에

넓은 것부터 좁혀 간다.

Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 → Redis → 쿠키 모양

1-1. 먼저 Grafana Ingress 를 백업한다

이것을 잊으면 실험이 끝나도 Grafana 가 안 돌아온다.

확인 — 지금 app2 가 무엇인지 먼저 본다

curl -sI https://app2.hyeonworks.com/ | head -3

형태 — Grafana 로 가고 있으면 302/login 을 가리킨다.

하기 — 백업을 뜨고, 파일이 비지 않았는지 확인한다

sudo kubectl -n observability get ingress grafana -o yaml > ~/grafana-ingress-backup.yaml
wc -l ~/grafana-ingress-backup.yaml
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml

어디를 봐야 하는가 — 줄 수가 0 이 아니고, app2.hyeonworks.com1회 이상 잡혀야 한다. 0 이면 백업이 빈 파일이고, 그 상태로 진행하면 복구할 것이 없다.

하기

sudo kubectl -n observability delete ingress grafana

실측01-deploy.txt

=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
  grafana ingress 삭제

되돌리기sudo kubectl apply -f ~/grafana-ingress-backup.yaml

1-2. oauth2-proxy 를 배포한다

하기

sudo kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s

실측01-deploy.txt

secret/oauth2-proxy-secrets created
deployment.apps/oauth2-proxy created
service/oauth2-proxy created
ingress.networking.k8s.io/oauth2-proxy created
deployment "oauth2-proxy" successfully rolled out

되돌리기sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml

1-3. replica 두 개가 서로 다른 노드에 있는가

확인

sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide

실측01-deploy.txt

oauth2-proxy-c76b49c59-8p5hl   true   kc-lab-1
oauth2-proxy-c76b49c59-b9928   true   kc-lab-2

어디를 봐야 하는가파드 두 개, 서로 다른 노드. 그리고 파드 이름의 끝 다섯 글자를 적어 둔다. 4절에서 로그를 읽을 때 「어느 replica 가 무엇을 했는지」를 이 글자로 가른다.

이 결과가 의미하는 것 — replica 가 둘이라는 것이 Q1 의 질문 자체다. 하나면 「공유」라는 말이 성립하지 않는다.

1-4. 진입점이 살아 있는가

확인

curl -s -o /dev/null -w '/     %{http_code}\n' https://app2.hyeonworks.com/
curl -s -o /dev/null -w '/ping %{http_code}\n' https://app2.hyeonworks.com/ping

실측01-deploy.txt

=== 진입점 확인 ===
  https://app2.hyeonworks.com/  HTTP 302
  /ping                         HTTP 200

어디를 봐야 하는가

경로 정상
/ 302 인증이 없으니 Keycloak 으로 보낸다 — 프록시가 일하고 있다
/ping 200 인증을 거치지 않는 헬스 경로 — 프록시 자체는 살아 있다

두 값이 갈라지는 것이 중요하다. /ping 도 안 되면 프록시가 안 뜬 것이고, /ping 만 되면 프록시는 떴는데 앞단이 무언가를 막고 있는 것이다.

1-5. ★ 502 가 나면 — 계층을 가른다

원래 구성에서 콜백이 계속 502 였다. 이 절은 그때 무엇을 쳤는지다. 502 를 안 만났으면 읽고 넘어간다.

GET /oauth2/callback?state=...&code=...   →  502 Bad Gateway

502 는 「누가 냈는지」를 안 알려 준다. 앞단 nginx 인지, 그 뒤 Traefik 인지, 파드인지. 한 겹씩 벗겨서 좁힌다.

확인 — nginx 를 건너뛰고 Traefik 에 직접 묻는다

curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/

실측

curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping   →  200
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/       →  302

어디를 봐야 하는가Traefik 직접은 정상이다. 그러면 502 를 내는 것은 그 앞의 nginx 다. 그리고 502 는 쿠키를 설정하는 응답에서만 났다.

이 결과가 의미하는 것 — oauth2-proxy 는 기본적으로 세션 전체를 쿠키에 담는다.Set-Cookie 가 nginx 의 proxy_buffer_size 를 넘겼다.

B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다. 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가 프록시 버퍼를 넘겨 502 가 됐다. 같은 종류의 한계다.

해결 — 세션을 Redis 로 옮긴다. 매니페스트에 이미 들어 있다.

확인

sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
  -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i session

형태

"--session-store-type=redis"
"--redis-connection-url=redis://redis.keycloak-lab.svc:6379"

★ 그리고 여기서 조용한 실패를 하나 만난다

nginx 설정을 보려던 시도가 계속 빈 결과였다.

실측

$ sudo -n true
sudo: a password is required

test-server(호스트)의 sudo 는 비밀번호를 요구한다. 게스트(kc-lab-1/2)는 무암호라 A층에서 conntrack·tc 를 문제없이 썼는데, 호스트는 다르다.

앞선 「nginx 로그가 비어 있다」는 관측은 로그가 없던 것이 아니라 sudo 가 조용히 실패한 것이었다. 호스트에서 무언가가 빈 결과를 주면 먼저 sudo -n true 를 쳐 본다.

1-6. secret 이 두 개 들어 있는가 — 값은 안 찍는다

확인 — 키 이름만 본다

sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
  -o jsonpath='{.data}' | tr ',' '\n' | grep -o '"[A-Z_]*"'

형태

"CLIENT_SECRET"
"COOKIE_SECRET_A"
"COOKIE_SECRET_B"

확인 — 길이만 본다. 값은 절대 찍지 않는다

sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
  -o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
  -o jsonpath='{.data.COOKIE_SECRET_B}' | base64 -d | wc -c

어디를 봐야 하는가oauth2-proxy 는 정확히 16·24·32 바이트만 받는다. 매니페스트의 값은 32바이트짜리다. 다른 수가 나오면 프록시가 기동에서 죽는다. 미검증 — 원래 실행 기록에 이 명령의 출력은 없다.

이 결과가 의미하는 것회전 대상이 미리 두 개 준비되어 있다. 이것이 이 실험을 「한 번 바꾸고 되돌릴 수 있는」 형태로 만든다.

1-7. 로그인해서 세션을 하나 만든다

하기 — 브라우저에서

https://app2.hyeonworks.com/api/echo   →  labuser / labpass

어디를 봐야 하는가 — Keycloak 로그인 화면이 뜨고, 통과하면 upstream(echo)의 JSON 이 보인다.

이 결과가 의미하는 것 — upstream 이 받은 헤더가 그대로 찍힌다.

실측b7-oauth2proxy-login-success.png

"x-forwarded-email"              : [ "labuser@example.com" ],
"x-forwarded-preferred-username" : [ "labuser" ],
"x-forwarded-user"               : [ "27df5ea9-8703-4ec5-badd-d972c583e1ff" ],
"x-forwarded-proto"              : [ "https" ]

B-4 에서 「위조가 통한다」고 측정한 바로 그 헤더를 oauth2-proxy 가 붙인다. Forward-Auth 구조의 신원 전달 방식이고, B-4 의 결론이 그대로 적용된다 — edge 가 붙인 것과 공격자가 보낸 것을 upstream 은 구별하지 못한다.

1-8. 세션이 Redis 에 들어갔는가

확인 — 먼저 통째로 본다

sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize

실측03-rotation.txt

=== 세션이 Redis 에 들어갔는가 ===
b5:pvc
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
  dbsize: 2

어디를 봐야 하는가dbsize 는 2 인데 세션은 하나다. b5:pvc 는 B-5 가 남긴 키이고 이 실험과 무관하다.

이 결과가 의미하는 것dbsize 로 세션을 세면 틀린다. 이 Redis 는 이 실험 전용이 아니다. 세션만 세려면 접두사로 좁힌다.

확인

sudo kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*'

KEYS 대신 --scan 을 쓴다. KEYS 는 Redis 를 블로킹한다. 실험대에서는 티가 안 나지만 습관을 여기서 들인다.

1-9. 쿠키가 「티켓」인지 확인한다

세션 저장소를 Redis 로 옮기면 쿠키에는 세션 전체가 아니라 티켓만 담긴다.

하기 — 브라우저 개발자 도구 → Application/저장소 → Cookies → _oauth2_proxy

실측 — 해설 문서에 남은 값

쿠키: _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpO...|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=
       └─ 세션 전체가 아니라 티켓이다 (약 180자)

어디를 봐야 하는가| 로 나뉜 세 토막과 전체 길이.

_oauth2_proxy=<ticket>|<timestamp>|<mac>
                └─ Redis 키를 여기서 계산한다

이 결과가 의미하는 것 — 쿠키가 짧아졌고(그래서 502 가 사라졌고), Redis 키 이름은 이 티켓에서 계산된다. 4절의 「지우지 못한다」가 여기서 결정된다.


2. 주입 — secret 을 A 에서 B 로 바꾼다

여기부터 상태가 바뀐다. 되돌리는 명령을 먼저 읽어 둔다.

되돌리기

sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
  -p '[{"op":"replace",
        "path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
        "value":"COOKIE_SECRET_A"}]'

2-1. 먼저 「겹칠 수 있는가」를 묻는다

바꾸기 전에 이것부터 확인한다. B-6 의 무중단 회전이 여기서도 되는지가 Q1 의 질문 자체이기 때문이다.

확인

sudo kubectl -n keycloak-lab exec deploy/oauth2-proxy -- \
  /bin/oauth2-proxy --help 2>&1 | grep cookie-secret

실측03-rotation.txt

        --cookie-secret string                                the seed string for secure cookies (optionally base64 encoded)

어디를 봐야 하는가string. 복수형이 아니다. --cookie-secrets--old-cookie-secret 도 목록에 없다.

이 결과가 의미하는 것겹침 구간을 만들 수단이 아예 없다. B-6 에서 Keycloak 이 두 키를 동시에 들 수 있었던 것은 토큰 헤더에 kid 가 있어서였다. oauth2-proxy 의 쿠키에는 그런 식별자가 없다.

   식별자 있음  →  읽기는 여러 key, 쓰기는 하나  →  겹침 가능   (B-6)
   식별자 없음  →  전부 한 번에 바뀐다           →  겹침 불가   (B-7)

이 한 줄이 이 실험의 답이다. 나머지는 「그래서 실제로 무슨 일이 나는가」다.

2-2. env 배열의 어느 칸을 바꾸는지 먼저 확인한다

아래 patch 는 env/1 을 지목한다. 매니페스트의 순서에 달린 값이다. 그대로 믿지 말고 확인한다.

확인

sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
  -o jsonpath='{.spec.template.spec.containers[0].env[*].name}'; echo

형태

OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET

어디를 봐야 하는가OAUTH2_PROXY_COOKIE_SECRET몇 번째인가 (0부터 센다). 위 형태에서는 두 번째이므로 env/1 이다. 순서가 다르면 patch 의 숫자를 고친다. 틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다.

2-3. 바꾼다

하기

date -u '+%H:%M:%S UTC 회전'
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
  -p '[{"op":"replace",
        "path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
        "value":"COOKIE_SECRET_B"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s

실측03-rotation.txt

=== ★ secret 을 A → B 로 교체한다 ===
deployment.apps/oauth2-proxy patched
deployment "oauth2-proxy" successfully rolled out

시각을 UTC 로 적어 둔다. 프록시 로그가 UTC 이고, B-7a 의 정리 규칙이 이 시각을 기준으로 고아를 고른다.


3. 교체가 실제로 걸렸는지 확인한다

3-1. 지금 어느 키를 참조하는가

확인

sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
  -o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo

실측03-rotation.txt

  현재 secret 키: COOKIE_SECRET_B

어디를 봐야 하는가COOKIE_SECRET_B. Deployment 의 참조가 바뀐 것이지 Secret 의 내용이 바뀐 것이 아니다. 두 값 다 그대로 있고 어느 쪽을 읽을지만 바뀌었다 — 그래서 되돌리기가 한 줄이다.

3-2. ★ 그런데 Redis 는 그대로다

확인

sudo kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*'

실측03-rotation.txt

  Redis 세션은 그대로인가: 2 키

어디를 봐야 하는가 — 세션 수가 회전 전과 같다.

이 결과가 의미하는 것회전 자체는 아무 일도 일으키지 않는다. 여기서 「실험 실패」라고 결론 내리면 틀린다. 무슨 일이 나려면 누군가 옛 쿠키를 들고 와야 한다. 그게 4절이다.

이 「회전만으로는 아무 일도 안 난다」를 초 단위로 확정한 것이 B-7a 의 기준선이다.

3-3. 파드가 실제로 새로 떴는가

확인

sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide

어디를 봐야 하는가파드 이름이 1-3 과 다르다. 같으면 patch 가 아무 필드도 안 바꾼 것이다(이미 B 였거나 경로가 틀렸다). 3-1 로 돌아간다.


4. 관찰 — 옛 쿠키를 들고 가 본다

4-1. 브라우저로 다시 연다

하기 — 1-7 에서 로그인한 그 브라우저 그대로

https://app2.hyeonworks.com/api/echo

어디를 봐야 하는가로그인 화면이 뜨는가.

실측 — 뜨지 않았다. 화면이 잠깐 깜빡이고 그대로 열린다.

이 결과가 의미하는 것Keycloak SSO 세션이 살아 있어서 조용히 재인증이 일어났다. 쿠키는 분명히 무효가 됐는데 사용자 눈에는 아무 일도 없었다.

여기가 이 실험에서 가장 오해하기 쉬운 자리다. 「로그인 화면이 안 떴으니 교체가 무중단이구나」로 읽으면 정확히 반대로 읽은 것이다. 쿠키는 죽었고, 사용자는 실제로 재인증을 거쳤다. SSO 가 그 사실을 가려 준 것뿐이다. IdP SSO 가 없거나 만료됐으면 전원이 로그인 화면을 본다.

4-2. 로그가 무슨 일이 났는지 말한다

확인 — 먼저 최근 로그를 그대로 본다

sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m --prefix

한 번은 통째로 본다. 어떤 줄이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다. --prefix 는 각 줄 앞에 파드 이름을 붙여 준다 — replica 가 둘이므로 이게 없으면 누가 무엇을 했는지 못 가린다.

이제 좁힌다.

확인

sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m | grep -i stored_session

실측03-rotation.txt

[2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: <nil>, removing session
[2026/09/04 05:42:18] [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation: <nil>

어디를 봐야 하는가두 줄이 다른 말을 하고 있다.

stored_session.go:94 쿠키를 열 수 없다 → 세션을 지우겠다
stored_session.go:97 그 지우기가 실패했다error decoding ticket to clear session

94 만 보고 「정리됐구나」로 읽으면 틀린다. 97 이 진짜 결과다.

이어지는 줄이 사용자 쪽 이야기다.

실측

[2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login.
... [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...

어디를 봐야 하는가Initiating loginAuthSuccess같은 초에 있다. 로그인 흐름이 실제로 돌았고, 사람 손이 안 들어갔다. 4-1 에서 화면이 깜빡였던 것이 이것이다.

4-3. ★ Redis 에 고아가 남는다

확인

sudo kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*'

실측03-rotation.txt

=== Redis 세션 수 (옛 세션이 남아 있는가) ===
  _oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
  _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
  총: 2 개

어디를 봐야 하는가키가 둘이다. 뒤엣것(b26111f…)은 1-8 에서 본 회전 전의 세션이고, 앞엣것이 4-1 에서 새로 생긴 것이다.

이 결과가 의미하는 것사용자는 하나인데 서버 세션이 둘이다. 옛 것은 아무도 쓸 수 없고 아무도 지울 수 없다. 고아다.

4-4. 왜 못 지우는가 — 티켓과 키의 관계

무엇인가. Redis 세션 저장소를 쓰면 쿠키에는 티켓만 담긴다(1-9). 티켓은 두 부분이다.

   티켓 = <세션 ID>.<암호화 키>
            │            └─ 값을 복호화할 키
            └─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>

왜 여기 나오나. 티켓 전체가 cookie secret 으로 봉인되어 있다. secret 을 바꾸면 티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다.

없거나 틀리면. 정확히 지금 상황이다 — 프록시는 「이 세션은 못 쓴다」까지는 알지만 「그 세션이 Redis 어디에 있다」를 모른다. 그래서 removing session 을 시도하고 실패한다(4-2 의 97 번 줄).

   secret 교체
     └─ 옛 티켓을 못 푼다
          ├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
          └─ ★ 서버 세션은 TTL 만료까지 고아로 남는다

로그인한 사용자 수만큼 고아가 생긴다. 여기서 이 실험은 멈췄다. 「정말 사라지는가 · 운영자는 지울 수 있는가 · 어느 것이 고아인지 아는가」를 B-7a 가 이어서 잰다. 답은 「지울 수 있다」이고, 「지울 수 없다」는 oauth2-proxy 의 한계였지 Redis 의 한계가 아니었다.

4-5. 덤 — replica 를 넘어도 되는 이유

로그를 파드별로 갈라 보면 BFF 와 정반대인 성질이 보인다.

확인

sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=10m --prefix \
  | grep -E 'Initiating login|AuthSuccess'

실측 — 해설 문서에 남은 형태

--- replica 8p5hl ---
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
GET "/api/echo"                                   ← 흐름을 시작한 replica

--- replica b9928 ---
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
GET "/oauth2/callback?state=..."                  ← 콜백을 받은 replica

어디를 봐야 하는가시작한 파드와 콜백을 처리한 파드가 다른데 성공했다.

이 결과가 의미하는 것

인가 요청(state, CSRF)을 어디에 두는가 replica 간
BFF 서버 메모리(HttpSession) 콜백이 다른 인스턴스로 가면 실패 (B-0)
oauth2-proxy 쿠키 (secret 으로 봉인) secret 만 같으면 성공

Q1 이 물은 「어떻게 공유하는가」의 답이 이것이다 — 공유할 상태가 없고, 공유할 것은 k8s Secret 하나뿐이다. 대신 그 하나가 단일 지점이 된다.


5. 복구

5-1. secret 을 A 로 되돌린다

하기

date -u '+%H:%M:%S UTC 되돌림'
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
  -p '[{"op":"replace",
        "path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
        "value":"COOKIE_SECRET_A"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s

어디를 봐야 하는가이것도 회전이다. B 로 만든 세션이 이번에는 고아가 된다. 되돌리기가 공짜가 아니라는 것이 이 실험의 성질 그대로다.

5-2. 고아를 정리한다

확인 — 지금 몇 개 남았는지 센다

sudo kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*'

세 가지 선택지가 있다.

언제
그냥 둔다 실험대 TTL(1시간)이 지나면 사라진다
TTL 로 골라 지운다 산 세션을 살리고 싶을 때 B-7a 의 규칙
전부 지운다 어차피 다 무효일 때 아래

하기 — 전부 지울 때. b5:pvc 같은 남의 키를 같이 죽이지 않도록 패턴으로 좁힌다

sudo kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
    sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
  done

FLUSHDB 를 쓰지 않는다. 이 Redis 는 BFF 세션도 담고 있다(C-1 에서 확인). 1-8 에서 dbsize 가 2 였던 이유를 여기서 다시 쓴다.

5-3. ★ Grafana Ingress 를 되돌린다

이것을 빠뜨리면 Grafana 가 안 열린다.

하기

sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f ~/grafana-ingress-backup.yaml

확인 — 실제로 Grafana 로 돌아갔는지 본다

sudo kubectl -n observability get ingress grafana
curl -sI https://app2.hyeonworks.com/ | head -3

어디를 봐야 하는가 — Ingress 가 observability 에 다시 있고, app2 응답이 1-1 에서 본 모양으로 돌아왔는가.

두 Ingress 가 같은 host 를 동시에 들고 있으면 안 된다. oauth2-proxy 것을 먼저 지우고 Grafana 것을 올린다. 순서를 바꾸면 어느 쪽으로 갈지가 컨트롤러 판단에 맡겨진다.

oauth2-proxy 전체를 걷어내려면

sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml

다만 B-7aC-1 이 이 배포를 그대로 쓴다. 이어서 할 생각이면 남겨 둔다 — 그때는 Grafana Ingress 복구도 그 실험이 끝난 뒤로 미룬다.

5-4. 원상복구 확인표

항목 명령 돌아왔을 때
secret 참조 get deploy oauth2-proxy -o jsonpath='{...env[1]...key}' COOKIE_SECRET_A
파드 sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy 둘 다 1/1 Running
Redis redis-cli --scan --pattern '_oauth2_proxy-*' 남기기로 한 만큼만
Ingress (빌린 것) sudo kubectl -n keycloak-lab get ingress oauth2-proxy 것이 없다 (걷어냈다면)
Ingress (Grafana) sudo kubectl -n observability get ingress grafana 있다
curl -sI https://app2.hyeonworks.com/ | head -3 Grafana 로 간다

막히면

전부 이 실험대가 실제로 겪은 증상이다. 지어낸 것은 없다.

증상 원인 확인
콜백이 502 Bad Gateway 쿠키가 크다. Set-Cookie 가 nginx 버퍼를 넘겼다 Traefik 직접이 200 인지 — 1-5. Redis 세션 저장소로 옮긴다
호스트에서 nginx 설정·로그가 빈 결과 sudo 가 조용히 실패했다 sudo -n truesudo: a password is required — 1-5
--cookie-secrets 를 찾는데 없다 단수다. 겹침 구간이 애초에 없다 --help | grep cookie-secret — 2-1
patch 뒤 프록시가 기동에서 죽는다 env 인덱스를 잘못 짚어 클라이언트 비밀을 덮었다 env[*].name 순서 확인 — 2-2
secret 을 바꿨는데 Redis 가 그대로 정상이다. 옛 쿠키를 들고 오는 요청이 있어야 벌어진다 3-2
로그인 화면이 안 떠서 「무중단」이라 읽었다 SSO 가 재인증을 가렸다. 쿠키는 죽었다 로그의 Initiating login + AuthSuccess — 4-2
로그가 파드마다 섞여 못 읽겠다 replica 가 둘이다 logs -l app=oauth2-proxy --prefix — 4-2
dbsize 로 세션을 셌더니 안 맞는다 b5:pvc 등 다른 키가 섞인다 --scan --pattern '_oauth2_proxy-*' — 1-8
파드 IP 로 /oauth2/auth 를 쳤더니 HTTP 000 호스트에서 파드 IP 는 안 닿는다 (02) 공개 이름으로 치거나 클러스터 안 임시 파드를 쓴다
curl 로 OIDC 흐름을 완주하려다 실패 쿠키가 HttpOnly 이고 폼을 거쳐야 한다 브라우저를 쓴다 — 전제
로그 시각이 9시간 어긋난다 프록시 로그는 UTC 표시 규약의 박스
app2 가 Grafana 로 간다 Ingress 를 안 만들었거나 이미 복구했다 sudo kubectl -n keycloak-lab get ingress
실험이 끝났는데 Grafana 가 안 열린다 Ingress 복구를 안 했다 5-3

다음

실험 B-7 이 남긴 질문
B-7a 고아 세션 정말 사라지는가 · 지울 수 있는가 · 어느 것이 고아인지 아는가 — 셋 다 답이 나온다
C-1 다중 앱 SSO app1(BFF)과 app2(oauth2-proxy)가 준비됐다. 서로 다른 구조로 같은 IdP 를 쓴다
D-3 비밀 관리 cookie secret 이 k8s Secret 에 평문이다
운영 secret 교체는 무중단이 아니다. 트래픽이 적은 창을 고르고 고아를 정리한다