Files
document-haness/.run/keycloak-four-patterns/records/case-browser-credential-boundary.md
T

10 KiB

id, kind, slug, title, topic, project, status, version, verifiedOn, studio, public
id kind slug title topic project status version verifiedOn studio public
bf675775-4f3e-4744-8014-f0efff51422a CASE spa-browser-credential-boundary SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계 OAuth/OIDC 인증 경계 KeyCloak Patterns 게시 중 25 2026-08-22 https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit https://hyeonworks.com/cases/spa-browser-credential-boundary

SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계

SPA를 public OAuth client로 구성해 authorization code를 직접 교환하고, access·refresh·ID token은 JavaScript memory에 보관했다. Web Storage에는 token을 저장하지 않았고, 실행 중인 script가 같은 JavaScript 실행 영역의 token과 API 호출에 접근할 수 있는지도 함께 확인했다.

관계

  • Authorization Code Flow의 Endpoint와 Credential 이동 기준 브라우저가 authorization endpoint와 token endpoint를 직접 호출하는 흐름을 코드와 network 요청으로 확인했다.
  • Public Client와 Confidential Client 구분 기준 SPA는 client secret을 안전하게 보관할 수 없어 public client로 등록했고, Authorization Code Flow에는 PKCE를 적용했다.
  • OAuth Token과 Application Session을 구분하는 기준 JavaScript memory의 OAuth token과 Keycloak 도메인의 SSO cookie가 서로 다른 상태라는 점을 확인했다.
  • 인증 구조를 보안 성숙도 단계로 취급하지 않는다 이 Case의 SPA 구성을 다른 패턴보다 낮은 단계로 해석하지 않도록 별도의 결정 기록에서 기준을 정했다.

문제

token을 Web Storage에 저장하지 않고 JavaScript memory에만 보관했을 때 XSS 경계가 어떻게 달라지는지 확인할 필요가 있었다.

AP1은 SPA가 public client가 되어 authorization code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에 두는 구성이다.

확인할 내용은 세 가지였다. 브라우저가 어떤 credential을 직접 다루는지, PKCE가 어떤 공격을 막는지, memory-only 보관으로 제한할 수 있는 위험이 무엇인지였다.

결론

memory-only 보관은 token을 Web Storage에 지속적으로 저장하지 않는 방법이다. 실행 중 XSS가 같은 JavaScript 실행 영역에 접근하는 문제까지 해결하지는 않는다.

실행 중 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. token 원문은 memory에만 있는 것이 아니라 요청마다 Authorization 헤더에도 실리기 때문에 노출된다.

Resource Server는 STATELESS로 동작하고 별도 session이나 denylist를 두지 않았다. 이미 발급된 self-contained JWT는 logout만으로 즉시 무효화되지 않으므로 짧은 만료 시간과 refresh token rotation을 사용하고, Resource Server에서는 issuer와 audience를 검증한다.

PKCE는 훔친 authorization code의 교환을 막을 뿐이지 발급된 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

재현 조건

  1. SPA를 열고 로그인후 Keycloak authorization request의 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인.

  2. token 응답에 access·refresh·ID token이 비어 있지 않은지 확인.

  3. 브라우저 fetch를 hook해 /api/me 호출의 Authorization header에서 Bearer access token을 확인.

  4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인.

  5. 같은 정상 JWT를 expected issuer·audience가 다른 diagnostic server 두 곳에 제출해 401을 확인.

  6. refresh token으로 새 token을 받고 이전 refresh token이 거부되는지, revocation 뒤 refresh가 실패하는지, 이미 발급된 access JWT가 만료 전까지 200인지 확인.

본문

브라우저가 직접 다루는 credential

:::evidence key="ap1-custody-v3-6e0376d2" alt="브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." caption=" " zoom="true" :::

code 교환, token 보관, Authorization 헤더 조립이 모두 같은 브라우저 실행 영역에 있다. 이 영역에서 악성 script가 실행되면 세 지점 모두 영향을 받는다.

새로고침 전후의 브라우저 상태

oidc-client-ts의 InMemoryWebStorage는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다. 새로고침하면 JavaScript memory의 User와 token이 초기화되고, Local Storage와 Session Storage에서는 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과 별개

memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아니다.

memory-only가 줄이는 위험

저장 위치만으로 XSS 경계를 설명할 수는 없다. 같은 origin에서 악성 script가 실행되면 JavaScript memory와 fetch 호출 모두 같은 실행 영역에 있기 때문이다.

위협 memory-only가 막아주나
새로고침 뒤에도 남는 token 복사본 막아준다
실행 중 script가 fetch를 가로채기 막아주지 않는다
실행 중 script가 사용자 대신 API 호출 막아주지 않는다
network 요청 헤더에 실린 access token 막아주지 않는다
이미 발급된 access JWT의 만료 전 유효성 막아주지 않는다

네 번째 줄이 이 코드에서 access token이 외부 요청으로 나가는 지점이다. SPA는 요청마다 이 헤더를 만든다.

GET http://localhost:8081/api/me
Authorization: Bearer <access-token>

token 원문은 memory에도 있고 network 헤더에도 실린다.

Resource Server가 SessionCreationPolicy.STATELESS라서 서버에 지울 session이 없다. 이미 발급된 self-contained JWT를 logout 순간에 즉시 없앨 방법이 없고, logout은 Keycloak SSO 종료와 애플리케이션 user 제거를 다룰 뿐 access JWT를 deny-list에서 관리하지 않는다.

이 구성에서는 access token의 만료 시간을 짧게 두어 노출됐을 때 사용할 수 있는 시간을 제한한다. access token : 300초 refresh token rotation, 재사용 허용 : x issuer·audience : 검증

Local Storage나 Session Storage로 옮기면 새로고침은 편해지지만 노출 시간이 길어진다. HttpOnly cookie로 옮기는 일은 저장 위치만 바꾸는 작업이 아니다. server가 session이나 token 중계를 맡는 구조가 필요하다.

PKCE가 막는 구간

PKCE(Proof Key for Code Exchange)는 authorization request에 code_challenge를 싣고, code를 token으로 바꿀 때 원본인 code_verifier를 같이 보내게 한다. 둘이 맞아야 교환이 끝난다.

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가 Authorization Code Flow를 쓴다는 뜻이고, code_challengecode_challenge_method=S256이 PKCE 사용을 나타낸다. 막는 구간은 code 교환까지다. 이미 발급된 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의 파라미터를 보는 것이지 PKCE 교환이 성립하는 것을 보는 것이 아니다.

:::warning

SPA는 non-2xx 응답에서도 response.ok을 확인하기 전에 response.json()을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 오류 처리보다 JSON parse error가 먼저 발생한다.

:::

추가로 설정에서 확인해야될 것

local realm의 redirect allowlist는 http://localhost:8088/*http://127.0.0.1:8088/* wildcard다. SPA : /OAuth2callback.html만 o, exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x

frontend Nginx에도 /api/ proxy가 있지만 SPA는 상대 URL이 아니라 absolute http://localhost:8081/api/me를 사용한다. 브라우저는 8088에서 8081로 cross-origin 요청을 보내므로 Resource Server의 CORS allowlist가 실제 요청에 적용된다. 상대 URL을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다.