The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
id, kind, slug, title, topic, topicName, project, status, version, verifiedOn, studio, public, assets
| id | kind | slug | title | topic | topicName | project | status | version | verifiedOn | studio | public | assets | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| bf675775-4f3e-4744-8014-f0efff51422a | CASE | spa-browser-credential-boundary | SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우 | oauth-oidc-auth-boundary | OAuth/OIDC 인증 경계 | KeyCloak Patterns | 게시 중 | 31 | 2026-08-22 | https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit | https://hyeonworks.com/cases/spa-browser-credential-boundary |
|
SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
AP1에서는 SPA를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 access token, refresh token, ID token은 Web Storage에 저장하지 않고 JavaScript memory에만 둔다.
이렇게 하면 새로고침 뒤에는 token이 남지 않는다. 하지만 페이지가 열려 있는 동안에는 JavaScript에서 token을 사용하고 있고, Resource Server를 호출할 때도 access token을 Authorization 헤더에 넣는다. 실행 중 XSS가 발생했을 때 영향을 받는 부분은 그대로 남아 있다.
관계
- Authorization Code Flow의 Endpoint와 Credential 이동 기준 SPA에서 authorization request를 보내고 authorization code를 받은 뒤 token을 교환하는 과정을 직접 확인한 내용이다.
- Public Client와 Confidential Client 구분 기준 SPA는 client secret을 안전하게 숨길 수 없기 때문에 public client로 구성했고 PKCE S256을 사용했다.
- OAuth Token과 Application Session을 구분하는 기준 JavaScript memory에 있는 token과 Keycloak의 SSO cookie는 서로 다른 상태다. 새로고침 뒤 SPA의 token이 없어져도 Keycloak의 SSO 상태는 남아 있을 수 있다.
문제
AP1에서는 token을 Local Storage나 Session Storage에 저장하지 않고 JavaScript memory에만 둔다.
확인하고 싶었던 부분은 token을 Web Storage에 저장하지 않는 것만으로 실행 중 XSS까지 막을 수 있는지였다.
SPA가 authorization code를 직접 교환한 뒤 token을 어디에 가지고 있는지, API를 호출할 때 access token이 어디를 지나는지 확인했다. PKCE도 실제로 어느 구간에 적용되는지 같이 봤다.
결론
memory-only로 보관하면 새로고침 뒤에는 access token, refresh token, ID token이 남지 않는다.
하지만 페이지가 실행 중일 때는 JavaScript에서 token을 사용한다. 악성 script가 같은 페이지에서 실행되면 fetch를 가로채거나 사용자를 대신해서 API를 호출할 수 있다. access token도 JavaScript memory에만 있는 것이 아니라 Resource Server 요청의 Authorization 헤더에 들어간다.
Resource Server는 SessionCreationPolicy.STATELESS로 동작한다. 서버에서 삭제할 application session이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 없다.
현재 구성에서는 access token 수명을 300초로 두고 refresh token rotation을 사용한다. Resource Server에서는 issuer와 audience도 확인한다.
PKCE는 authorization code를 token으로 교환하는 구간에 사용한다. 이미 발급된 access token을 브라우저에서 숨겨주는 기능은 아니다.
검증 환경
Keycloak 26.7.0
realms 설정
public-client, standard flow : o
implicit flow, direct grant : x
authority : http://localhost:8080/realms/keycloak-patterns
redirect_uri : http://localhost:8088/OAuth2callback.html
scope : openid profile email
userStore : InMemoryWebStorage
stateStore : sessionStorage
automaticSilentRenew : true
Resource Server
SessionCreationPolicy.STATELESS
CSRF x
CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Type
HTTPS : x
HTTP : o
재현 조건
-
SPA를 열고 로그인한 뒤 Keycloak authorization request에서 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인한다.
-
token 응답의 access token, refresh token, ID token이 비어 있지 않은지 확인한다.
-
브라우저 fetch를 hook하고 /api/me 요청의 Authorization 헤더에서 Bearer access token을 확인한다.
-
Local Storage와 Session Storage에 access token substring이 남지 않는지 확인한다.
-
같은 정상 JWT를 expected issuer와 audience가 다른 diagnostic server 두 곳에 보내고 401이 반환되는지 확인한다.
-
refresh token으로 새 token을 받은 뒤 이전 refresh token이 거부되는지 확인한다. revocation 뒤에는 refresh가 실패하는지, 이미 발급된 access JWT는 만료 전까지 200을 받는지도 확인한다.
본문
SPA에서 Token을 처리하는 위치
:::evidence key="ap1-custody-v3-6e0376d2" alt="브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." caption=" " zoom="true" :::
authorization code 교환, token 보관, Authorization 헤더 생성까지 모두 브라우저에서 처리한다.
access token, refresh token, ID token도 JavaScript memory에 있고 Resource Server를 호출할 때 사용할 Authorization 헤더도 같은 페이지에서 만든다.
그래서 이 페이지에서 악성 script가 실행되면 JavaScript가 token을 사용하는 부분에도 접근할 수 있다.
새로고침 전후에 브라우저에 남는 값
oidc-client-ts의 InMemoryWebStorage를 사용해서 로그인 결과를 Local Storage나 Session Storage에 저장하지 않고 실행 중 memory에만 둔다.
새로고침하면 memory에 있던 로그인 정보와 token은 사라진다.
| 위치 | reload 전 | reload 후 |
|---|---|---|
| JavaScript memory | User, access·refresh·ID token, expiry, profile |
사라짐 |
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
| Local Storage | 해당 없음 | 해당 없음 |
| Keycloak origin cookie | IdP의 SSO 상태가 존재할 수 있음 | application과 별개 |
JavaScript memory에 있던 User가 사라지는 것과 Keycloak의 SSO session이 끝나는 것은 별개다.
SPA에서 가지고 있던 token이 사라져도 Keycloak SSO cookie가 남아 있으면 이후 authorization request에서 기존 로그인 상태가 다시 사용될 수 있다.
Memory-only로 막을 수 있는 범위
memory-only로 바꾼 뒤 어떤 값이 없어지고 어떤 부분은 그대로 남는지 확인했다.
| 위협 | memory-only가 막아주나 |
|---|---|
| 새로고침 뒤에도 남는 token 복사본 | 막아준다 |
| 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 |
| 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 |
| network 요청 헤더에 실린 access token | 막아주지 않는다 |
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
Resource Server를 호출할 때 SPA에서 access token을 Authorization 헤더에 넣는다.
GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
그래서 access token은 JavaScript memory에만 존재하는 값은 아니다. API를 호출하는 동안에는 network 요청의 Authorization 헤더에도 들어간다.
Resource Server는 SessionCreationPolicy.STATELESS로 설정되어 있어서 서버에서 삭제할 application session이 없다.
이미 발급된 self-contained JWT를 logout 시점에 바로 무효화하는 처리도 넣지 않았다. logout에서는 Keycloak SSO 종료와 SPA의 user 제거를 처리하고, 발급된 access JWT를 deny-list로 따로 관리하지 않는다.
현재 access token 수명은 300초다.
access token : 300초 refresh token rotation, 재사용 허용 : x issuer·audience : 검증
Local Storage나 Session Storage에 token을 저장하면 새로고침 이후에도 값을 다시 읽을 수 있지만, 브라우저 저장소에도 token이 남게 된다.
HttpOnly cookie를 사용하려면 현재 SPA처럼 브라우저에서 access token을 꺼내 Resource Server로 직접 보내는 방식과는 달라진다. server가 session이나 token 전달을 맡는 구조가 필요하다.
PKCE가 적용되는 구간
PKCE(Proof Key for Code Exchange)를 사용할 때 authorization request에는 code_challenge가 들어가고, authorization code를 token으로 교환할 때는 원본인 code_verifier를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.
response_type=code
client_id=spa-public
redirect_uri=http://localhost:8088/OAuth2callback.html
scope=openid profile email
state=<opaque-state>
code_challenge=<opaque-challenge>
code_challenge_method=S256
이번 설정에서는 response_type=code를 사용하고 code_challenge_method=S256과 비어 있지 않은 code_challenge가 authorization request에 들어가는 것을 확인했다.
PKCE가 적용되는 곳은 authorization code를 token으로 교환하는 구간이다. token이 발급된 이후 access token을 브라우저에서 사용하지 못하게 하는 기능은 아니다.
테스트에서 확인한 범위
커밋된 테스트에서 확인하도록 만들어 둔 항목은 다음과 같다.
| 정의 여부 | 정의 내용 |
|---|---|
| o | authorization request의 response_type=code, S256 method, 비어 있지 않은 challenge |
| o | token 응답에 비어 있지 않은 access·refresh·ID token |
| o | /api/me 200과 decoded access token의 audience 포함 |
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측 |
| o | Local Storage와 Session Storage에 access token substring 없음 |
| o | refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패 |
| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |
| x | token request body의 code_verifier·client_id·redirect_uri·code 값 대조 |
| x | 서명이 깨진 JWT, 만료된 JWT |
| x | 브라우저 간 요청(CORS)의 preflight 응답 |
| x | callback에 error가 실려 돌아왔을 때의 화면 |
| x | automaticSilentRenew의 실제 갱신 경로 |
authorization request에서는 response_type=code, S256 method, 비어 있지 않은 challenge까지 확인했다.
하지만 token request body에서 실제 code_verifier, client_id, redirect_uri, code 값이 어떻게 전달됐고 서로 대조됐는지는 아직 확인하지 않았다.
:::warning
SPA는 non-2xx 응답에서도 response.ok을 확인하기 전에 response.json()을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지 대신 JSON parse error가 먼저 노출되게 된다.
:::
Redirect URI와 CORS에서 아직 확인하지 않은 부분
local realm의 redirect allowlist는 다음과 같이 wildcard로 설정되어 있다.
http://localhost:8088/*
http://127.0.0.1:8088/*
SPA에서 실제 사용하는 callback은 /OAuth2callback.html이다.
SPA : /OAuth2callback.html만 o
exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x
현재 설정에서는 wildcard가 허용되어 있기 때문에 exact callback만 허용했을 때 잘못된 redirect가 거부되는지는 아직 확인하지 않았다.
frontend Nginx에도 /api/ proxy가 있지만 SPA에서는 상대 URL을 사용하지 않고 absolute URL인 http://localhost:8081/api/me를 호출한다.
그래서 현재 요청은 브라우저에서 Resource Server로 직접 나가고 CORS allowlist를 거친다. 상대 URL을 사용해서 Nginx를 통해 호출했다면 현재와 같은 CORS 경로는 지나지 않았을 것이다.