Files
document-haness/.run/keycloak-four-patterns/records/case-ap3-bff-session-csrf.md
T

14 KiB

id, kind, slug, title, topic, project, status, version, verifiedOn, studio, public
id kind slug title topic project status version verifiedOn studio public
d85bd6af-7599-4ef7-9407-6609927d5b5c CASE bff-session-csrf-responsibility BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정 OAuth/OIDC 인증 경계 KeyCloak Patterns 게시 중 28 2026-08-25 https://hyeonworks.com/studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/edit https://hyeonworks.com/cases/bff-session-csrf-responsibility

BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정

로그인 후 브라우저는 AP3_SESSION으로 BFF를 호출하고, BFF가 server-side authorized client에서 access token을 조회해 Resource Server를 호출한다. 상태 변경 요청에는 XSRF-TOKENX-XSRF-TOKEN 검증을 추가했다.

관계

  • BFF 인증 구조 설계 기준 이 기준이 요구하는 항목 중 무엇이 구현됐고 무엇이 구현되지 않았는지
  • OAuth Token과 Application Session을 구분하는 기준 session cookie와 CSRF token, server-side token을 각각 다뤄야 하는 이유
  • OAuth/OIDC 인증 패턴 선택 기준 BFF 구조에서 필요한 CSRF 검증과 server-side 상태 저장 기준을 함께 다룬다
  • BFF가 OAuth Token을 관리하는 조건 이 결정의 구조를 실제로 실행해 본 문서
  • 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가 두 상태가 모두 process-local memory에 있다는 점이 질문의 시작이다
  • BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가 session과 authorized client의 2가지 흐름

문제

BFF에서는 confidential-client인 BFF서버가 code를 교환하고 access token과 refresh token은 server-side authorized client에 관리하게 된다. 브라우저에는 HttpOnly AP3_SESSION만 전달된다.

그런데 브라우저는 여전히 요청마다 cookie를 보낸다. cookie가 credential이면 상태를 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다. BFF는 이제 재시작과 replica 이동에 따른 저장소가 필요하다.

BFF가 OAuth token을 관리하도록 구성한 뒤 브라우저 요청 방식과 CSRF 처리, server-side 저장 상태를 확인했다.

결론

상태 변경 요청에서 브라우저가 보내는 cookie는 AP3_SESSIONXSRF-TOKEN이다.

AP3_SESSION : HttpOnly, JavaScript 읽기 x XSRF-TOKEN : JavaScript 읽기 o

브라우저는 session cookie를 요청에 자동으로 포함한다. 상태 변경 요청에서는 CSRF token을 별도로 검증하며, JavaScript가 헤더 값을 만들 수 있도록 XSRF-TOKEN cookie에는 HttpOnly를 사용하지 않았다.

BFF를 사용해도 same-origin XSS는 별도로 막아야 한다. 악성 script가 실행되면 현재 session으로 BFF를 호출할 수 있고 JavaScript에서 읽을 수 있는 CSRF cookie에도 접근할 수 있다. 다만 OAuth token 원문을 브라우저 JavaScript에 전달하지는 않는다.

현재 구현에서는 BFF가 CSRF 검증까지 처리한다. 재시작 뒤 로그인 유지, replica 공유 session, 저장 token 암호화, logout, downstream 오류 변환, timeout, 경로별 인가 : x

검증 환경

Keycloak 26.7.0

realms confidential, client_secret_basic PKCE S256 : o provider : authorization-code, refresh-token

store : memory o CSRF : o HTTP : o

재현 조건

  1. UI에서 로그인하고 authorization request를 확인. client_id : bff-confidential code_challenge_method : S256

  2. 브라우저 요청 목록에 Keycloak token endpoint와 8081 직접 호출이 없는지 확인.

  3. cookie가 AP3_SESSION이며 HttpOnly와 SameSite=Lax인지, Web Storage가 비었는지 확인.

  4. /bff/token-boundary를 호출. accessTokenStoredOnServer : true refreshTokenStoredOnServer : true browserTokenCount : 0 csrfProtectionEnabled : true

  5. /bff/api/me가 200이고 downstream 응답에 username과 audience가 있는지 확인.

  6. GET /bff/csrf로 XSRF-TOKEN cookie와 token metadata를 받는거 확인. 응답 본문의 token과 cookie 값이 같은 문자열이 아님을 확인.

  7. session cookie는 있고 CSRF 헤더가 없는 POST /bff/api/preferences가 403인지 확인.

  8. cookie의 raw 값을 X-XSRF-TOKEN에 넣은 같은 POST가 200이고 theme이 dark인지 확인.

  9. 127.0.0.1에서 localhost로 보내는 cross-site POST에서 AP3_SESSION이 요청에 실리지 않는지 확인.

본문

BFF가 Resource Server를 호출하는 흐름

:::evidence key="ap3-bff-custody-82fa18bd" alt="브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." caption="" zoom="true" :::

브라우저는 BFF endpoint를 session cookie로 호출한다. BFF는 authorized client에서 access token을 가져와 Resource Server 요청의 Authorization 헤더를 만든다.

브라우저가 전송하는 session과 CSRF token

무엇 브라우저에 있나 JavaScript가 읽나
AP3_SESSION o x
XSRF-TOKEN o o
access token x x
refresh token x x

JavaScript는 XSRF-TOKEN cookie 값을 읽어 상태 변경 요청의 X-XSRF-TOKEN 헤더에 넣는다. 이 용도 때문에 XSRF-TOKEN에는 HttpOnly를 사용하지 않았다.

same-origin에서 악성 script가 실행되면 사용자의 session으로 BFF endpoint를 호출할 수 있고 XSRF-TOKEN도 읽을 수 있다. BFF 구조의 차이는 OAuth token 원문을 브라우저 JavaScript에 전달하지 않는다는 점이다.

Session으로 Authorized Client를 조회하는 과정

브라우저 요청에는 Authorization 헤더도 없고 코드에도 access token 지역 변수도 없다.

GET http://localhost:8083/bff/api/me
Accept: application/json
Cookie: AP3_SESSION=<opaque-session-id>

cookie 자체는 token을 들고 있지 않다. cookie가 session을 식별하고, 그 session에서 얻은 인증 주체로 authorized client를 찾는다.

AP3_SESSION
  → HttpSession
  → SecurityContext
  → Authentication.getName()
  → ("keycloak", principal name)
  → OAuth2AuthorizedClientService
  → access token + refresh token

BffController.currentUser(Authentication)OAuth2AuthorizeRequest를 만들어 OAuth2AuthorizedClientManager.authorize()를 호출한다. manager bean은 AuthorizedClientServiceOAuth2AuthorizedClientManager이고 authorization-code와 refresh-token provider를 함께 사용하므로 만료된 access token의 갱신도 이 경로에서 처리한다.

없으면 401이 된다.

있으면 BFF의 RestClient가 downstream 입력을 새로 조립한다.

GET http://app:8081/api/me
Authorization: Bearer <server-held-access-token>

AP3_SESSION은 downstream으로 전달되지 않는다. BFF가 session을 해당 session에 맞는 token을 조회 후, Resource Server가 아는 Bearer credential로 바꾼다. 두 credential은 같은 요청 안에 있지만 서로 다른 경계로 나뉘게 된다.

:::warning

Compose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish한다. 테스트는 AP3 UI가 8081을 직접 부르지 않는다는 것만 확인.

:::

browserTokenCount는 무엇을 증명하나

진단용 endpoint가 server custody를 boolean으로 보여 준다.

{
  "pattern": "AP3-backend-for-frontend",
  "principal": "regular-user",
  "accessTokenStoredOnServer": true,
  "refreshTokenStoredOnServer": true,
  "browserTokenCount": 0,
  "csrfProtectionEnabled": true
}

browserTokenCount: 0은 브라우저를 실제로 검사해 센 값이 아니라 controller가 넣는 literal이다. 이 field 하나로는 token 비노출을 말할 수 없다.

밖에서 따로 봤다. 로그인 이후 개발자 도구에서 요청 목록과 저장소를 확인했더니 Keycloak token endpoint 호출이 없었고 Resource Server의 8081 직접 호출도 없었다. localStorage와 sessionStorage에도 accessToken·refreshToken 문자열이 없었다.

self-report          /bff/token-boundary → browserTokenCount: 0
external observation 브라우저 network    → token endpoint 없음
                     Web Storage         → token 문자열 없음

자기 자신을 보고하는 값과 밖에서 관측한 값을 같은 증거로 취급하지 않는다.

이 endpoint는 manager의 authorize()를 호출하지 않고 OAuth2AuthorizedClientService를 직접 조회하므로 여기서는 refresh를 수행하지 않는다.

cookie가 credential이면 CSRF가 필요하다

브라우저는 session cookie를 요청마다 자동으로 붙인다. GET만 보면 이것이 문제로 보이지 않는다. 값을 바꾸는 요청에서 보이게 되는데, 먼저 브라우저가 CSRF material을 받는다.

HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/
{
  "headerName": "X-XSRF-TOKEN",
  "parameterName": "_csrf",
  "token": "<xor-masked-csrf-token>"
}

같은 endpoint가 두 값을 반환하게 되는데, 이 둘은 같은 문자열이 아니다.

:::evidence key="ap3-csrf-split-501dd1f7" alt="BFF의 CSRF endpoint 하나에서 두 갈래가 갈리는 그림. 위쪽은 raw token이 담긴 XSRF-TOKEN cookie, 아래쪽은 가려진 token과 headerName이 담긴 JSON body다. 두 갈래가 POST 조립 단계로 모이지만 실제 X-XSRF-TOKEN 값은 cookie의 raw token이고 JSON에서는 headerName만 쓴다. 마지막으로 CSRF filter가 대조한다." caption="" zoom="true" :::

CookieCsrfTokenRepository.withHttpOnlyFalse()가 cookie에 raw 값을 넣는다. XorCsrfTokenRequestAttributeHandler가 request attribute용 token을 XOR와 Base64로 가리기 때문에 응답 본문에는 가려진 값이 보인다.

SPA는 본문의 token을 쓰지 않는다. 본문에서는 headerName만 읽고, document.cookie에서 raw XSRF-TOKEN을 찾아 헤더 값으로 넣는다.

body.token          masked token
cookie XSRF-TOKEN   raw token
X-XSRF-TOKEN        raw token
POST /bff/theme HTTP/1.1
Host: localhost:8083
Content-Type: application/json

Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
X-XSRF-TOKEN: <raw-csrf-token>
{
   "theme":"dark"
}

SpaCsrfTokenRequestHandler가 노출하는 형태와 제출받는 형태를 나눠 처리한다. 요청 헤더에 raw 값이 실려 오면 그 값을 cookie와 대조한다.

:::note

응답 본문의 token을 가리는 것은 BREACH 완화다. HTTP 응답 압축 크기의 차이로 응답 안의 비밀값을 좁혀 가는 공격이라 노출되는 형태를 매번 다르게 만든다.

:::

SameSite와 CSRF token이 막는 입력

네 가지 입력으로 나눠 보면 둘이 갈린다.

입력 막는 것 응답
same-origin, 헤더 없음 CSRF token 403
same-site 다른 port, 헤더 없음 CSRF token 403
cross-site POST SameSite cookie 누락
same-origin, 값 일치 통과 200

앞의 두 요청에는 cookie가 포함되므로 CSRF token 검증이 필요하다. 셋째 요청은 cookie가 전송되지 않는다. port가 달라도 site 계산상 같은 경우가 있어 SameSite만으로 둘째 요청을 차단할 수는 없다.

셋째 줄의 관측 지점은 최종 status가 아니라 cookie가 요청에서 빠졌다는 부분이다.

BFF에서 관리해야 하는 항목

현재 BFF 구현에서 직접 관리하는 항목은 다음과 같다.

관리 항목 현재 구현
상태 변경 요청의 CSRF 검증 o
재시작 뒤 로그인 유지 x
replica가 함께 쓰는 session x
저장 token 암호화 x
logout 때 session과 authorized client 삭제 x
downstream 오류를 화면 오류로 변환 x
timeout · retry · circuit breaker x
경로별 인가 x

첫 줄만 구현돼 있다. 나머지는 이 BFF가 단일 인스턴스 memory와 한 번의 BFF 호출로만 보여 준다.

저장소도 생각한 모양이 아니다. 현재 store는 session ID마다 독립된 token 저장소가 아니라 registration과 principal name으로 authorized client를 찾는 애플리케이션 수준 store다. 같은 principal이 여러 브라우저 session에서 로그인하면 같은 항목을 공유하거나 덮어쓸 수 있다.

확인한 것과 확인하지 않은 것

아래는 커밋된 자동 테스트가 확인하도록 정의한 부분이다.

항목 확인했나
bff-confidential + S256 challenge o
브라우저 요청에 token endpoint 없음 o
브라우저 요청에 8081 직접 호출 없음 o
AP3_SESSION HttpOnly · SameSite=Lax o
Web Storage 비어 있음 o
server access·refresh boolean이 true o
/bff/api/me 200 · username · audience o
CSRF 헤더 없는 POST 403 o
raw 값을 헤더에 넣은 POST 200 o
cross-site POST에서 cookie 누락 o
preference의 사용자별 격리 x
preference 영속성 x
공유 session store x
저장 token 암호화 x
logout x
downstream 401의 전달 모양 x
timeout · 경로별 인가 x

이 구조에서 관측한 것

브라우저 network에서는 Keycloak token endpoint를 직접 호출하지 않았고 /bff/api/me에도 Authorization: Bearer가 없었다. 해당 요청은 AP3_SESSION으로 인증됐으며, 상태 변경 요청에는 CSRF token 검증을 적용했다. 현재 로그인 session과 authorized client는 BFF process memory에 저장된다.

브라우저에 OAuth token을 전달하지 않고 backend가 여러 API 호출을 조합해야 하는 요구에는 BFF가 맞다. 브라우저가 Resource Server를 직접 호출해야 한다면 SPA나 Mediator 구조를 검토한다.