- generic [ref=f3e3]: - link "본문으로 건너뛰기" [ref=f3e4] [cursor=pointer]: - /url: "#main-content" - banner [ref=f3e5]: - generic [ref=f3e6]: - link "TechLog Studio" [ref=f3e7] [cursor=pointer]: - /url: /studio - text: TechLog - generic [ref=f3e8]: Studio - navigation "Studio 주 탐색" [ref=f3e10]: - link "작업본" [ref=f3e11] [cursor=pointer]: - /url: /studio/documents - link "게시 기록" [ref=f3e12] [cursor=pointer]: - /url: /studio/publications - link "새 문서" [ref=f3e13] [cursor=pointer]: - /url: /studio/documents/new - link "주제·프로젝트" [ref=f3e14] [cursor=pointer]: - /url: /studio/taxonomy - link "릴리즈" [ref=f3e15] [cursor=pointer]: - /url: /studio/releases - link "공개 사이트 보기" [ref=f3e16] [cursor=pointer]: - /url: / - button "로그아웃" [ref=f3e17] - main [ref=f3e18]: - generic [ref=f3e382]: - generic [ref=f3e383]: - region [ref=f3e384]: - generic [ref=f3e385]: - paragraph [ref=f3e386]: CASE · VERSION 25 - heading "문서 편집" [level=1] [ref=f3e387] - paragraph [ref=f3e388]: SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계 - region [ref=f3e389]: - generic [ref=f3e390]: - paragraph [ref=f3e391]: DOCUMENT - heading "기본 정보" [level=2] [ref=f3e392] - generic [ref=f3e393]: - generic [ref=f3e394]: - generic [ref=f3e395]: 제목 - textbox "제목" [ref=f3e396]: SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계 - generic [ref=f3e397]: - generic [ref=f3e398]: slug - textbox "slug" [ref=f3e399]: - /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈) - text: spa-browser-credential-boundary - generic [ref=f3e400]: - generic [ref=f3e401]: 요약 - textbox "요약" [ref=f3e402]: SPA가 public OAuth client로 code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에만 두는 AP1을 실행했다. memory-only는 새로고침 뒤 남는 복사본만 없앨 뿐, 실행 중 script가 fetch를 가로채거나 사용자 대신 API를 부르는 부분은 남아있다. - generic [ref=f3e403]: - generic [ref=f3e404]: Topic - combobox "Topic" [ref=f3e405]: - option "선택하지 않음" - option "OAuth/OIDC 인증 경계" [selected] - generic [ref=f3e406]: - generic [ref=f3e407]: Project - combobox "Project" [ref=f3e408]: - option "미지정" - option "Backend Clean Architecture" - option "KeyCloak Patterns" [selected] - option "Liner N + 1문제" - group "관계" [ref=f3e409]: - generic [ref=f3e411]: - generic [ref=f3e412]: - generic [ref=f3e413]: 관계 1 대상 - combobox "관계 1 대상" [ref=f3e414]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" [disabled] - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [selected] - option "BFF 인증 구조 설계 기준" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "Browser에서 OAuth Token을 제거해야 하는 경우 BFF가 Token을 소유한다" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다" - option "외부 IdP Federation과 Application 인증 경계" - option "OAuth/OIDC 인증 패턴 선택 기준" - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "Public Client와 Confidential Client 구분 기준" [disabled] - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f3e415]: - generic [ref=f3e416]: 관계 1 이유 - textbox "관계 1 이유" [ref=f3e417]: 브라우저가 authorization endpoint와 token endpoint를 모두 지나는 흐름이다. 이 기준의 endpoint별 이동을 코드로 확인한 자리다. - generic [ref=f3e418]: - button "위로" [disabled] [ref=f3e419] - button "아래로" [ref=f3e420] - button "삭제" [ref=f3e421] - generic [ref=f3e422]: - generic [ref=f3e423]: - generic [ref=f3e424]: 관계 2 대상 - combobox "관계 2 대상" [ref=f3e425]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" [disabled] - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [disabled] - option "BFF 인증 구조 설계 기준" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "Browser에서 OAuth Token을 제거해야 하는 경우 BFF가 Token을 소유한다" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다" - option "외부 IdP Federation과 Application 인증 경계" - option "OAuth/OIDC 인증 패턴 선택 기준" - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "Public Client와 Confidential Client 구분 기준" [selected] - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f3e426]: - generic [ref=f3e427]: 관계 2 이유 - textbox "관계 2 이유" [ref=f3e428]: SPA가 secret을 숨길 수 없어 public client가 되고 PKCE가 그 자리를 대신한 실례다. - generic [ref=f3e429]: - button "위로" [ref=f3e430] - button "아래로" [ref=f3e431] - button "삭제" [ref=f3e432] - generic [ref=f3e433]: - generic [ref=f3e434]: - generic [ref=f3e435]: 관계 3 대상 - combobox "관계 3 대상" [ref=f3e436]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" [disabled] - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [disabled] - option "BFF 인증 구조 설계 기준" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "Browser에서 OAuth Token을 제거해야 하는 경우 BFF가 Token을 소유한다" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다" - option "외부 IdP Federation과 Application 인증 경계" - option "OAuth/OIDC 인증 패턴 선택 기준" - option "OAuth Token과 Application Session을 구분하는 기준" [selected] - option "Public Client와 Confidential Client 구분 기준" [disabled] - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f3e437]: - generic [ref=f3e438]: 관계 3 이유 - textbox "관계 3 이유" [ref=f3e439]: memory-only 보관과 IdP SSO cookie를 나눠 본 자리다. 브라우저에 없다는 말의 대상을 여기서 좁혔다. - generic [ref=f3e440]: - button "위로" [ref=f3e441] - button "아래로" [ref=f3e442] - button "삭제" [ref=f3e443] - generic [ref=f3e444]: - generic [ref=f3e445]: - generic [ref=f3e446]: 관계 4 대상 - combobox "관계 4 대상" [ref=f3e447]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" [selected] - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [disabled] - option "BFF 인증 구조 설계 기준" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "Browser에서 OAuth Token을 제거해야 하는 경우 BFF가 Token을 소유한다" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다" - option "외부 IdP Federation과 Application 인증 경계" - option "OAuth/OIDC 인증 패턴 선택 기준" - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "Public Client와 Confidential Client 구분 기준" [disabled] - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f3e448]: - generic [ref=f3e449]: 관계 4 이유 - textbox "관계 4 이유" [ref=f3e450]: 이 기록이 성숙도 모델의 출발점으로 오해되기 쉬운 자리다. 그 오해를 막는 결정이다. - generic [ref=f3e451]: - button "위로" [ref=f3e452] - button "아래로" [disabled] [ref=f3e453] - button "삭제" [ref=f3e454] - button "관계 추가" [ref=f3e455] - region [ref=f3e456]: - generic [ref=f3e457]: - paragraph [ref=f3e458]: CASE - heading "문제와 검증" [level=2] [ref=f3e459] - generic [ref=f3e460]: - generic [ref=f3e461]: - generic [ref=f3e462]: 문제 - textbox "문제" [ref=f3e463]: token을 Web Storage에 저장하지 않고 memory에만 두면 XSS 위험도 사라지는지 확인할 필요가 있었다. AP1은 SPA가 public client가 되어 authorization code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에 두는 구성이다. 이 구성에서 실제로 무엇이 브라우저에 남고, PKCE가 어느 구간을 막으며, memory-only 보관이 어느 위험을 막고 어느 위험을 막아주지 않는지 구분해야 했다. - generic [ref=f3e464]: - generic [ref=f3e465]: 결론 - textbox "결론" [ref=f3e466]: memory-only 보관이 막아주는 것은 새로고침 뒤에도 남는 token 복사본이지 실행 중 XSS의 권한이 아니다. 실행 중 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. token 원문은 memory에만 있는 것이 아니라 요청마다 Authorization 헤더에도 실리기 때문에 노출된다. Resource Server가 STATELESS라 서버에 지울 session이 없고, 이미 발급된 self-contained JWT를 logout 순간에 없앨 방법도 없다. 그래서 이를 짧은 수명과 rotation, issuer·audience 검증이 커버하게 된다. PKCE는 훔친 authorization code의 교환을 막을 뿐이지 발급된 access token을 숨기지 않는다. - generic [ref=f3e467]: - generic [ref=f3e468]: 검증 환경 - textbox "검증 환경" [ref=f3e469]: "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" - generic [ref=f3e470]: - generic [ref=f3e471]: 재현 조건 - textbox "재현 조건" [ref=f3e472]: 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인지 확인. - generic [ref=f3e473]: - generic [ref=f3e474]: 마지막 검증일 - textbox "마지막 검증일" [ref=f3e475]: 2026-08-22 - generic [ref=f3e476]: - generic [ref=f3e477]: 본문 Markdown - textbox "본문 Markdown" [ref=f3e478]: "## 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에만 둔다. 새로고침하면 로그인 상태가 사라지지만 Web Storage에 남아있는 복사본은 없다. 아래 표는 새로고침을 기준으로 무엇이 남고 무엇이 사라지는지 나눈 것이다. | 위치 | 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)으로 script가 실행되면 저장 위치는 더 이상 경계가 아니다. 같은 실행 영역 안이기 때문이다. | 위협 | memory-only가 막아주나 | |---|---| | 새로고침 뒤에도 남는 token 복사본 | 막아준다 | | 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 | | 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 | | network 요청 헤더에 실린 access token | 막아주지 않는다 | | 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 | 네 번째 줄이 이 코드에서 access token이 외부 요청으로 나가는 지점이다. SPA는 요청마다 이 헤더를 만든다. ```http label=\"브라우저가 Resource Server를 직접 부를 때\" GET http://localhost:8081/api/me Authorization: Bearer ``` token 원문은 memory에도 있고 network 헤더에도 실린다. Resource Server가 `SessionCreationPolicy.STATELESS`라서 서버에 지울 session이 없다. 이미 발급된 self-contained JWT를 logout 순간에 즉시 없앨 방법이 없고, logout은 Keycloak SSO 종료와 애플리케이션 user 제거를 다룰 뿐 access JWT를 deny-list에서 관리하지 않는다. **그래서 수명을 짧게 두는 것이 안전하다.** 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`를 같이 보내게 한다. 둘이 맞아야 교환이 끝난다. ```text label=\"oidc-client-ts가 만드는 authorization request의 핵심 query\" response_type=code client_id=spa-public redirect_uri=http://localhost:8088/OAuth2callback.html scope=openid profile email state= code_challenge= code_challenge_method=S256 ``` `response_type=code`가 Authorization Code Flow를 쓴다는 뜻이고, `code_challenge`와 `code_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`를 쓴다. 그래서 CORS allowlist는 실제로 지나가는 경계이다. 상대 URL을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다." - group [ref=f3e479]: - paragraph [ref=f3e480]: EVIDENCE - heading "본문에 Asset 삽입" [level=3] [ref=f3e481] - paragraph [ref=f3e482]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다. - generic [ref=f3e483]: - generic [ref=f3e484]: - generic [ref=f3e485]: 업로드 종류 - combobox "업로드 종류" [ref=f3e486]: - option "이미지" [selected] - option "다이어그램" - option "첨부파일" - button "Asset 업로드" [ref=f3e487] - generic [ref=f3e488]: - search [ref=f3e489]: - generic [ref=f3e490]: Asset 검색 - generic [ref=f3e491]: - searchbox "Asset 검색" [ref=f3e492] - button "검색" [ref=f3e493] - generic [ref=f3e494]: - checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f3e495] - generic [ref=f3e496]: 삽입할 때 크게 보기 허용 - status [ref=f3e497]: 삽입할 수 있는 Asset 8개 - list [ref=f3e498]: - listitem [ref=f3e499]: - button "ap4-edge-trust-1cff2399" [ref=f3e500] - button "삭제" [ref=f3e501] - listitem [ref=f3e502]: - button "ap3-csrf-split-501dd1f7" [ref=f3e503] - button "삭제" [ref=f3e504] - listitem [ref=f3e505]: - button "ap3-bff-custody-82fa18bd" [ref=f3e506] - button "삭제" [ref=f3e507] - listitem [ref=f3e508]: - button "ap2-split-custody-779cb791" [ref=f3e509] - button "삭제" [ref=f3e510] - listitem [ref=f3e511]: - button "ap1-custody-v3-6e0376d2" [ref=f3e512] - button "삭제" [ref=f3e513] - listitem [ref=f3e514]: - button "ap1-custody-v2-e110bd98" [ref=f3e515] - button "삭제" [ref=f3e516] - listitem [ref=f3e517]: - button "ap1-credential-custody-f5e0c027" [ref=f3e518] - button "삭제" [ref=f3e519] - listitem [ref=f3e520]: - button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f3e521] - button "삭제" [ref=f3e522] - region [ref=f3e523]: - generic [ref=f3e524]: - paragraph [ref=f3e525]: LIVE - heading "즉시 미리보기" [level=2] [ref=f3e526] - generic [ref=f3e529]: - generic [ref=f3e530]: - navigation "문서 경로" [ref=f3e531]: - link "Case" [ref=f3e532] [cursor=pointer]: - /url: /explore/cases - generic [ref=f3e533]: / - generic [ref=f3e534]: OAuth/OIDC 인증 경계 - generic [ref=f3e535]: / - link "KeyCloak Patterns" [ref=f3e536] [cursor=pointer]: - /url: /projects/keycloak-patterns - heading "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" [level=1] [ref=f3e537] - paragraph [ref=f3e538]: SPA가 public OAuth client로 code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에만 두는 AP1을 실행했다. memory-only는 새로고침 뒤 남는 복사본만 없앨 뿐, 실행 중 script가 fetch를 가로채거나 사용자 대신 API를 부르는 부분은 남아있다. - region "문제와 결론" [ref=f3e539]: - generic [ref=f3e540]: - paragraph [ref=f3e541]: 문제 - paragraph [ref=f3e542]: token을 Web Storage에 저장하지 않고 memory에만 두면 XSS 위험도 사라지는지 확인할 필요가 있었다.AP1은 SPA가 public client가 되어 authorization code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에 두는 구성이다. 이 구성에서 실제로 무엇이 브라우저에 남고, PKCE가 어느 구간을 막으며, memory-only 보관이 어느 위험을 막고 어느 위험을 막아주지 않는지 구분해야 했다. - generic [ref=f3e543]: - paragraph [ref=f3e544]: 결론 - paragraph [ref=f3e545]: memory-only 보관이 막아주는 것은 새로고침 뒤에도 남는 token 복사본이지 실행 중 XSS의 권한이 아니다.실행 중 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. token 원문은 memory에만 있는 것이 아니라 요청마다 Authorization 헤더에도 실리기 때문에 노출된다.Resource Server가 STATELESS라 서버에 지울 session이 없고, 이미 발급된 self-contained JWT를 logout 순간에 없앨 방법도 없다. 그래서 이를 짧은 수명과 rotation, issuer·audience 검증이 커버하게 된다. PKCE는 훔친 authorization code의 교환을 막을 뿐이지 발급된 access token을 숨기지 않는다. - generic [ref=f3e546]: - generic [ref=f3e547]: - term [ref=f3e548]: 검증 환경 - definition [ref=f3e549]: "Keycloak 26.7.0realms 설정public-client, standard flow : o implicit flow, direct grant : xauthority : http://localhost:8080/realms/keycloak-patternsredirect_uri : http://localhost:8088/OAuth2callback.htmlscope : openid profile emailuserStore : InMemoryWebStoragestateStore : sessionStorageautomaticSilentRenew : trueResource ServerSessionCreationPolicy.STATELESS CSRF x CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-TypeHTTPS : x HTTP : o" - generic [ref=f3e550]: - term [ref=f3e551]: 검증 데이터 - definition [ref=f3e552]: 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인지 확인. - generic [ref=f3e553]: - term [ref=f3e554]: 기록 - definition [ref=f3e555]: 게시 2026.08.23 · 마지막 검증 2026.08.22 - group [ref=f3e557]: - generic "목차 · credential이 머무는 자리" [ref=f3e558] [cursor=pointer] - article [ref=f3e560]: - region [ref=f3e561]: - heading [level=2] [ref=f3e562]: - link "credential이 머무는 자리 바로가기" [ref=f3e563] [cursor=pointer]: - /url: "#credential이-머무는-자리" - text: credential이 머무는 자리 - generic [ref=f3e564]: "#" - figure [ref=f3e565]: - button "ap1-custody-v3-6e0376d2 이미지 크게 보기" [ref=f3e566]: - img "브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." [ref=f3e567] - generic [ref=f3e568]: 크게 보기 - generic [ref=f3e569]: 브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다. - paragraph [ref=f3e570]: - text: code 교환, token 보관, - code [ref=f3e571]: Authorization - text: 헤더 조립이 모두 같은 브라우저 실행 영역에 있다. 이 영역에서 악성 script가 실행되면 세 지점 모두 영향을 받는다. - region [ref=f3e572]: - heading [level=2] [ref=f3e573]: - link "브라우저에 실제로 남는 것 바로가기" [ref=f3e574] [cursor=pointer]: - /url: "#브라우저에-실제로-남는-것" - text: 브라우저에 실제로 남는 것 - generic [ref=f3e575]: "#" - paragraph [ref=f3e576]: - text: oidc-client-ts의 - code [ref=f3e577]: InMemoryWebStorage - text: 는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다.새로고침하면 로그인 상태가 사라지지만 Web Storage에 남아있는 복사본은 없다. - paragraph [ref=f3e578]: 아래 표는 새로고침을 기준으로 무엇이 남고 무엇이 사라지는지 나눈 것이다. - region "표" [ref=f3e579]: - table [ref=f3e580]: - caption [ref=f3e581] - rowgroup [ref=f3e582]: - row [ref=f3e583]: - columnheader "위치" [ref=f3e584] - columnheader "reload 전" [ref=f3e585] - columnheader "reload 후" [ref=f3e586] - rowgroup [ref=f3e587]: - row [ref=f3e588]: - cell "JavaScript memory" [ref=f3e589] - cell [ref=f3e590]: - code [ref=f3e591]: User - text: ", access·refresh·ID token, expiry, profile" - cell "사라짐" [ref=f3e592] - row [ref=f3e593]: - cell "Session Storage" [ref=f3e594] - cell "redirect transaction용 state와 verifier" [ref=f3e595] - cell "callback 완료 뒤 제거" [ref=f3e596] - row [ref=f3e597]: - cell "Local Storage" [ref=f3e598] - cell "해당 없음" [ref=f3e599] - cell "해당 없음" [ref=f3e600] - row [ref=f3e601]: - cell "Keycloak origin cookie" [ref=f3e602] - cell "IdP의 SSO 상태가 존재할 수 있음" [ref=f3e603] - cell "application과 별개" [ref=f3e604] - paragraph [ref=f3e605]: memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아니다. - region [ref=f3e606]: - heading [level=2] [ref=f3e607]: - link "memory-only가 줄이는 위험 바로가기" [ref=f3e608] [cursor=pointer]: - /url: "#memory-only가-줄이는-위험" - text: memory-only가 줄이는 위험 - generic [ref=f3e609]: "#" - paragraph [ref=f3e610]: 앞의 표는 "무엇이 어디 남지?"만 표현하고 있는데, 교차 사이트 스크립팅(XSS)으로 script가 실행되면 저장 위치는 더 이상 경계가 아니다. 같은 실행 영역 안이기 때문이다. - region "표" [ref=f3e611]: - table [ref=f3e612]: - caption [ref=f3e613] - rowgroup [ref=f3e614]: - row [ref=f3e615]: - columnheader "위협" [ref=f3e616] - columnheader "memory-only가 막아주나" [ref=f3e617] - rowgroup [ref=f3e618]: - row [ref=f3e619]: - cell "새로고침 뒤에도 남는 token 복사본" [ref=f3e620] - cell "막아준다" [ref=f3e621] - row [ref=f3e622]: - cell "실행 중 script가 fetch를 가로채기" [ref=f3e623] - cell "막아주지 않는다" [ref=f3e624] - row [ref=f3e625]: - cell "실행 중 script가 사용자 대신 API 호출" [ref=f3e626] - cell "막아주지 않는다" [ref=f3e627] - row [ref=f3e628]: - cell "network 요청 헤더에 실린 access token" [ref=f3e629] - cell "막아주지 않는다" [ref=f3e630] - row [ref=f3e631]: - cell "이미 발급된 access JWT의 만료 전 유효성" [ref=f3e632] - cell "막아주지 않는다" [ref=f3e633] - paragraph [ref=f3e634]: 네 번째 줄이 이 코드에서 access token이 외부 요청으로 나가는 지점이다. SPA는 요청마다 이 헤더를 만든다. - figure "HTTP ·브라우저가 Resource Server를 직접 부를 때 코드 복사" [ref=f3e635]: - generic [ref=f3e636]: - generic [ref=f3e637]: HTTP - generic [ref=f3e638]: ·브라우저가 Resource Server를 직접 부를 때 - button "코드 복사" [ref=f3e639] [cursor=pointer]: 복사 - region "브라우저가 Resource Server를 직접 부를 때 코드" [ref=f3e640]: - code [ref=f3e641]: "GET http://localhost:8081/api/me Authorization: Bearer " - paragraph [ref=f3e643]: token 원문은 memory에도 있고 network 헤더에도 실린다. - paragraph [ref=f3e644]: - text: Resource Server가 - code [ref=f3e645]: SessionCreationPolicy.STATELESS - text: 라서 서버에 지울 session이 없다.이미 발급된 self-contained JWT를 logout 순간에 즉시 없앨 방법이 없고, logout은 Keycloak SSO 종료와 애플리케이션 user 제거를 다룰 뿐 access JWT를 deny-list에서 관리하지 않는다. - paragraph [ref=f3e646]: - strong [ref=f3e647]: 그래서 수명을 짧게 두는 것이 안전하다. - text: "access token : 300초refresh token rotation, 재사용 허용 : xissuer·audience : 검증" - paragraph [ref=f3e648]: Local Storage나 Session Storage로 옮기면 새로고침은 편해지지만 노출 시간이 길어진다.HttpOnly cookie로 옮기는 일은 저장 위치만 바꾸는 작업이 아니다.server가 session이나 token 중계를 맡는 구조가 필요하다. - region [ref=f3e649]: - heading [level=2] [ref=f3e650]: - link "PKCE가 막는 구간 바로가기" [ref=f3e651] [cursor=pointer]: - /url: "#pkce가-막는-구간" - text: PKCE가 막는 구간 - generic [ref=f3e652]: "#" - paragraph [ref=f3e653]: - text: PKCE(Proof Key for Code Exchange)는 authorization request에 - code [ref=f3e654]: code_challenge - text: 를 싣고, code를 token으로 바꿀 때 원본인 - code [ref=f3e655]: code_verifier - text: 를 같이 보내게 한다. 둘이 맞아야 교환이 끝난다. - figure "TEXT ·oidc-client-ts가 만드는 authorization request의 핵심 query 코드 복사" [ref=f3e656]: - generic [ref=f3e657]: - generic [ref=f3e658]: TEXT - generic [ref=f3e659]: ·oidc-client-ts가 만드는 authorization request의 핵심 query - button "코드 복사" [ref=f3e660] [cursor=pointer]: 복사 - region "oidc-client-ts가 만드는 authorization request의 핵심 query 코드" [ref=f3e661]: - code [ref=f3e662]: response_type=code client_id=spa-public redirect_uri=http://localhost:8088/OAuth2callback.html scope=openid profile email state= code_challenge= code_challenge_method=S256 - paragraph [ref=f3e664]: - code [ref=f3e665]: response_type=code - text: 가 Authorization Code Flow를 쓴다는 뜻이고, - code [ref=f3e666]: code_challenge - text: 와 - code [ref=f3e667]: code_challenge_method=S256 - text: 이 PKCE 사용을 나타낸다.막는 구간은 code 교환까지다. 이미 발급된 access token은 막아주지 않는다. - region [ref=f3e668]: - heading [level=2] [ref=f3e669]: - link "확인한 것과 확인하지 않은 것 바로가기" [ref=f3e670] [cursor=pointer]: - /url: "#확인한-것과-확인하지-않은-것" - text: 확인한 것과 확인하지 않은 것 - generic [ref=f3e671]: "#" - paragraph [ref=f3e672]: - text: 아래는 - strong [ref=f3e673]: 커밋된 테스트가 확인하도록 정의한 부분 - text: 이다. 왼쪽이 정의 여부, 오른쪽이 정의 내용. - region "표" [ref=f3e674]: - table [ref=f3e675]: - caption [ref=f3e676] - rowgroup [ref=f3e677]: - row [ref=f3e678]: - columnheader "정의 여부" [ref=f3e679] - columnheader "정의 내용" [ref=f3e680] - rowgroup [ref=f3e681]: - row [ref=f3e682]: - cell "o" [ref=f3e683] - cell [ref=f3e684]: - text: authorization request의 - code [ref=f3e685]: response_type=code - text: ", S256 method, 비어 있지 않은 challenge" - row [ref=f3e686]: - cell "o" [ref=f3e687] - cell "token 응답에 비어 있지 않은 access·refresh·ID token" [ref=f3e688] - row [ref=f3e689]: - cell "o" [ref=f3e690] - cell [ref=f3e691]: - code [ref=f3e692]: /api/me - text: 200과 decoded access token의 audience 포함 - row [ref=f3e693]: - cell "o" [ref=f3e694] - cell "브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측" [ref=f3e695] - row [ref=f3e696]: - cell "o" [ref=f3e697] - cell "Local Storage와 Session Storage에 access token substring 없음" [ref=f3e698] - row [ref=f3e699]: - cell "o" [ref=f3e700] - cell "refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패" [ref=f3e701] - row [ref=f3e702]: - cell "o" [ref=f3e703] - cell "issuer나 audience가 다른 진단용 서버 두 곳의 401" [ref=f3e704] - row [ref=f3e705]: - cell "x" [ref=f3e706] - cell [ref=f3e707]: - text: token request body의 - code [ref=f3e708]: code_verifier - text: · - code [ref=f3e709]: client_id - text: · - code [ref=f3e710]: redirect_uri - text: ·code 값 대조 - row [ref=f3e711]: - cell "x" [ref=f3e712] - cell "서명이 깨진 JWT, 만료된 JWT" [ref=f3e713] - row [ref=f3e714]: - cell "x" [ref=f3e715] - cell "브라우저 간 요청(CORS)의 preflight 응답" [ref=f3e716] - row [ref=f3e717]: - cell "x" [ref=f3e718] - cell "callback에 error가 실려 돌아왔을 때의 화면" [ref=f3e719] - row [ref=f3e720]: - cell "x" [ref=f3e721] - cell [ref=f3e722]: - code [ref=f3e723]: automaticSilentRenew - text: 의 실제 갱신 경로 - paragraph [ref=f3e724]: - text: 첫 줄과 여덟째 줄을 같이 보자. - strong [ref=f3e725]: authorization request의 파라미터를 보는 것이지 PKCE 교환이 성립하는 것을 보는 것이 아니다. - complementary "주의" [ref=f3e726]: - paragraph [ref=f3e727]: 주의 - paragraph [ref=f3e728]: - text: SPA는 non-2xx 응답에서도 - code [ref=f3e729]: response.ok - text: 을 확인하기 전에 - code [ref=f3e730]: response.json() - text: 을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지 대신 JSON parse error가 먼저 노출되게 된다. - region [ref=f3e731]: - heading [level=2] [ref=f3e732]: - link "추가로 설정에서 확인해야될 것 바로가기" [ref=f3e733] [cursor=pointer]: - /url: "#추가로-설정에서-확인해야될-것" - text: 추가로 설정에서 확인해야될 것 - generic [ref=f3e734]: "#" - paragraph [ref=f3e735]: - text: local realm의 redirect allowlist는 - code [ref=f3e736]: http://localhost:8088/* - text: 와 - code [ref=f3e737]: http://127.0.0.1:8088/* - text: "wildcard다.SPA :" - code [ref=f3e738]: /OAuth2callback.html - text: 만 o,exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x - paragraph [ref=f3e739]: - text: frontend Nginx에도 - code [ref=f3e740]: /api/ - text: proxy가 있지만 SPA는 상대 URL이 아니라 absolute - code [ref=f3e741]: http://localhost:8081/api/me - text: 를 쓴다. 그래서 CORS allowlist는 실제로 지나가는 경계이다.상대 URL을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다. - region [ref=f3e742]: - paragraph [ref=f3e743]: Explicit relations - heading "이 기록의 연결" [level=2] [ref=f3e744] - list [ref=f3e745]: - listitem [ref=f3e746]: - link "브라우저가 authorization endpoint와 token endpoint를 모두 지나는 흐름이다. 이 기준의 endpoint별 이동을 코드로 확인한 자리다. Authorization Code Flow의 Endpoint와 Credential 이동 기준" [ref=f3e747] [cursor=pointer]: - /url: /references/authorization-code-endpoint-credential-movement - generic [ref=f3e748]: 브라우저가 authorization endpoint와 token endpoint를 모두 지나는 흐름이다. 이 기준의 endpoint별 이동을 코드로 확인한 자리다. - strong [ref=f3e749]: Authorization Code Flow의 Endpoint와 Credential 이동 기준 - generic [ref=f3e750]: ↗ - complementary [ref=f3e751]: - heading "작업 상태" [level=2] [ref=f3e752] - status "편집 상태" [ref=f3e753]: 저장됨 - generic [ref=f3e754]: - generic [ref=f3e755]: - term [ref=f3e756]: 저장 버전 - definition [ref=f3e757]: "25" - generic [ref=f3e758]: - term [ref=f3e759]: 종류 - definition [ref=f3e760]: CASE - paragraph [ref=f3e761]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다. - generic [ref=f3e762]: - button "저장" [disabled] [ref=f3e763] - button "게시" [ref=f3e764] - paragraph [ref=f3e381]