--- id: d85bd6af-7599-4ef7-9407-6609927d5b5c kind: CASE slug: bff-session-csrf-responsibility title: BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식 topic: oauth-oidc-auth-boundary topicName: OAuth/OIDC 인증 경계 project: KeyCloak Patterns status: 게시 중 version: 30 verifiedOn: 2026-08-25 studio: "https://hyeonworks.com/studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/edit" public: "https://hyeonworks.com/cases/bff-session-csrf-responsibility" assets: - key: ap3-bff-custody-82fa18bd file: ../../../final/assets/tech-log-studio/ap3-bff-custody.svg - key: ap3-csrf-split-501dd1f7 file: ../../../final/assets/tech-log-studio/ap3-csrf-split.svg --- # BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식 BFF 구조에서는 브라우저가 access token이나 refresh token을 받지 않는다. 로그인 이후 브라우저는 `AP3_SESSION`으로 BFF를 호출하고, access token과 refresh token은 BFF의 authorized client에 저장된다. 상태를 변경하는 요청도 session cookie를 사용하게 되면서 CSRF 검증이 추가됐다. 이때 브라우저에는 `XSRF-TOKEN`도 함께 사용된다. 현재 구현에서는 여기까지 확인했고, 재시작이나 여러 replica에서 session을 공유하는 부분은 아직 구현하지 않았다. ## 관계 - **BFF 인증 구조 설계 기준** BFF 구조에서 필요한 항목 중 현재 구현된 부분과 아직 구현하지 않은 부분을 확인한다. - **OAuth Token과 Application Session을 구분하는 기준** `AP3_SESSION`, `XSRF-TOKEN`, server-side access token과 refresh token이 각각 다른 위치에서 사용된다. - **OAuth/OIDC 인증 패턴 선택 기준** 브라우저에 OAuth token을 전달하지 않는 대신 BFF가 session과 token을 관리하고, 상태 변경 요청에는 CSRF 검증이 필요하다. - **BFF가 OAuth Token을 관리하는 조건** access token과 refresh token을 BFF가 보관하고 Resource Server 호출도 BFF가 수행하는 구조를 실제로 확인한다. - **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가** 현재 session과 authorized client가 모두 process-local memory에 있다는 점에서 시작한다. - **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가** HttpSession과 authorized client가 서로 다른 방식으로 조회되고 저장된다. ## 문제 BFF에서는 confidential client인 BFF 서버가 code를 교환하고 access token과 refresh token을 server-side authorized client에 저장한다. 브라우저에는 HttpOnly AP3_SESSION이 전달된다. 브라우저는 이후 요청마다 이 session cookie를 BFF로 보낸다. 상태를 변경하는 요청에서도 cookie는 자동으로 전송되기 때문에 session cookie만 확인해서는 해당 요청이 원래 페이지에서 보낸 요청인지 구분할 수 없다. 그래서 상태 변경 요청에는 CSRF 검증이 추가된다. 현재 session과 authorized client는 memory에 저장되어 있다. BFF를 재시작하거나 요청이 다른 replica로 이동하는 경우까지 처리하려면 이 상태를 어디에 저장할지도 따로 정해야 한다. 브라우저에서 OAuth token을 제거한 뒤 실제로 브라우저에 무엇이 남고 BFF에서 추가로 처리해야 하는 부분이 무엇인지 확인했다. ## 결론 브라우저에서는 두 개의 cookie를 사용한다. AP3_SESSION : HttpOnly, JavaScript 읽기 x XSRF-TOKEN : JavaScript 읽기 o AP3_SESSION은 브라우저가 요청을 보낼 때 자동으로 포함된다. 상태 변경 요청에서는 XSRF-TOKEN의 값을 X-XSRF-TOKEN 헤더에도 넣고 BFF가 이를 확인한다. JavaScript에서 값을 읽어 헤더에 넣어야 하기 때문에 XSRF-TOKEN은 HttpOnly가 아니다. XSS가 없어지는 것은 아니다. same-origin의 악성 script는 AP3_SESSION을 직접 읽을 수는 없지만, 브라우저가 session cookie를 붙인 상태로 BFF를 호출하게 할 수 있다. XSRF-TOKEN은 JavaScript에서 읽을 수도 있다. 이 구조에서 브라우저에 전달되지 않는 것은 access token과 refresh token 원문이다. 그래서 브라우저에서 유출된 OAuth token을 다른 client에서 사용하거나 Resource Server에 직접 보내는 형태의 재사용은 줄어든다. 현재 추가로 구현된 부분은 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이 요청에 실리지 않는지 확인한다. ## 본문 이 기록은 브라우저에서 OAuth 토큰을 없앤 구조에서 브라우저에 무엇이 남고 서버가 무엇을 더 맡게 되는지 확인한 것이다. BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호출하고 결과만 돌려주는 서버이고, 여기서는 로그인과 토큰 보관까지 이 서버가 맡는다. 먼저 브라우저에 남는 값부터 보고, 그 값으로 시작한 요청이 보호 자원 서버(Resource Server)로 가는 Bearer 요청이 되기까지를 따라간 다음, 상태를 바꾸는 요청에서 새로 필요해진 검사와 아직 구현하지 않은 항목을 적는다. ## 브라우저에는 쿠키 2개만 남는다 :::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의 `/oauth2/authorization/keycloak`을 여는 것으로 시작한다. 인가 요청에 실리는 클라이언트는 `bff-confidential`이고 `code_challenge_method`는 `S256`이다. 가로챈 authorization code를 그대로 바꿔 가지 못하도록 PKCE(Proof Key for Code Exchange)를 함께 걸었다. 다만 그 코드를 토큰으로 바꾸는 쪽은 브라우저가 아니다. BFF가 서버끼리 통신하는 자리에서 `client_secret_basic`으로 토큰 엔드포인트를 부르고, 받은 액세스 토큰과 리프레시 토큰은 `OAuth2AuthorizedClientService`가 관리하는 authorized client에 남는다. 브라우저가 받는 것은 `/`로 돌아가는 리다이렉트와 `AP3_SESSION` 쿠키뿐이다. 그래서 브라우저에 남는 값은 쿠키 2개다. `AP3_SESSION`에는 `HttpOnly`가 붙어 있어 브라우저에서 실행되는 스크립트가 값을 읽을 수 없고, 대신 BFF로 요청을 보낼 때마다 브라우저가 알아서 붙인다. `XSRF-TOKEN`에 `HttpOnly`를 붙이지 않은 것은 상태를 바꾸는 요청에서 스크립트가 이 값을 읽어 `X-XSRF-TOKEN` 헤더에 다시 넣어야 하기 때문이다. 액세스 토큰과 리프레시 토큰 2개는 어느 쪽으로도 브라우저에 내려가지 않는다. | 무엇 | 브라우저에 있나 | JavaScript가 읽나 | |---|---|---| | AP3_SESSION | o | x | | XSRF-TOKEN | o | o | | access token | x | x | | refresh token | x | x | 토큰 원문이 브라우저에서 사라졌다고 XSS(Cross-Site Scripting)까지 없어지는 것은 아니다. 같은 출처에서 실행되는 악성 스크립트는 `AP3_SESSION`을 직접 읽지 못해도 브라우저가 쿠키를 붙인 채 BFF를 부르게 만들 수 있고, 스크립트가 읽을 수 있는 `XSRF-TOKEN`에도 접근한다. 이 구조가 줄이는 것은 액세스 토큰과 리프레시 토큰 원문이 브라우저 스크립트에서 새어 나가 다른 클라이언트나 보호 자원 서버 직접 호출에 다시 쓰이는 범위다. ### 쿠키 하나로 시작한 요청이 Bearer 요청이 된다 브라우저가 `/bff/api/me`를 부를 때 요청에 붙는 자격 증명은 쿠키뿐이라, `Authorization` 헤더도 없고 브라우저 코드가 액세스 토큰을 변수에 담는 자리도 없다. ```http label="브라우저 입력 — cookie 하나" GET http://localhost:8083/bff/api/me Accept: application/json Cookie: AP3_SESSION= ``` `AP3_SESSION` 안에 토큰이 들어 있는 것은 아니다. 이 쿠키로 `HttpSession`을 찾고, 거기 저장된 `SecurityContext`에서 지금 로그인한 사용자의 `Authentication`을 꺼낸 다음, 그 이름으로 토큰 저장소를 조회한다. ```text label="cookie에서 Bearer까지" AP3_SESSION → HttpSession → SecurityContext → Authentication.getName() → ("keycloak", principal name) → OAuth2AuthorizedClientService → access token + refresh token ``` 이 조회를 하는 코드가 `BffController.currentUser(Authentication)`인데, 클라이언트 등록 이름 `"keycloak"`과 현재 `Authentication`으로 `OAuth2AuthorizeRequest`를 만들어 `OAuth2AuthorizedClientManager.authorize()`에 넘긴다. 이 자리에 놓인 manager는 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고, 코드 교환과 refresh를 각각 맡는 provider 2개를 함께 쓴다. 그래서 액세스 토큰이 만료됐고 리프레시 토큰이 남아 있으면 서버끼리 갱신을 시도할 수 있다. 저장소에 그 항목이 없거나 쓸 수 있는 액세스 토큰이 없으면 여기서 401이 되고, 이유로는 `No authorized Keycloak client is available`가 붙는다. 액세스 토큰을 얻으면 BFF의 `RestClient`가 보호 자원 서버로 보낼 요청을 새로 조립한다. ```http label="cookie로 조회된 토큰을 넣어서 조립" GET http://app:8081/api/me Authorization: Bearer ``` 브라우저가 보낸 `AP3_SESSION`은 이 요청에 실리지 않는다. BFF가 세션을 애플리케이션 쪽 자격 증명으로 소비하고, 보호 자원 서버가 이해하는 Bearer 자격 증명으로 바꾸기 때문이다. 보호 자원 서버는 AP1·AP2와 같은 무상태 JWT 경로에서 서명과 issuer, 시각, `keycloak-pattern-api` audience를 확인한 뒤 `subject`·`username`·`issuer`·`audience` 필드 4개짜리 JSON을 돌려주고, BFF는 그 응답을 그대로 브라우저에 중계한다. 그래서 `AP3_SESSION`은 브라우저와 BFF 사이에서만 쓰이고 Bearer 액세스 토큰은 BFF와 보호 자원 서버 사이에서만 쓰인다. :::warning Compose는 학습 편의를 위해 보호 자원 서버의 8081을 호스트 포트로도 내보낸다. 테스트가 확인하는 것은 AP3 화면이 8081을 직접 부르지 않는다는 것뿐이고, 모든 클라이언트가 BFF만 지나도록 네트워크로 강제했다는 뜻은 아니다. ::: ### `browserTokenCount: 0`이 증명하지 못하는 것 `/bff/token-boundary`는 서버가 토큰을 들고 있는지를 필드 6개짜리 JSON으로 알려 준다. ```json label="/bff/token-boundary 응답" { "pattern": "AP3-backend-for-frontend", "principal": "regular-user", "accessTokenStoredOnServer": true, "refreshTokenStoredOnServer": true, "browserTokenCount": 0, "csrfProtectionEnabled": true } ``` 이 가운데 `browserTokenCount: 0`은 브라우저를 검사해서 센 값이 아니라 컨트롤러가 응답에 그대로 써 넣는 고정값이다. 그래서 이 필드 하나로는 브라우저에 토큰이 없다는 것을 보일 수 없어 브라우저 쪽을 따로 확인했다. 로그인 뒤 개발자 도구에서 요청 목록을 봤을 때 Keycloak 토큰 엔드포인트 호출이 없었고, 보호 자원 서버의 8081을 직접 부르는 요청도 없었다. `localStorage`와 `sessionStorage` 어디에도 `accessToken`·`refreshToken` 문자열이 없었다. ```text label="같은 주장에 대한 두 종류의 근거" self-report /bff/token-boundary → browserTokenCount: 0 external observation 브라우저 network → token endpoint 없음 Web Storage → token 문자열 없음 ``` 앞의 것은 서버가 자기 상태를 스스로 보고한 값이고 뒤의 것은 브라우저에서 관찰한 값이라, 두 근거는 따로 기록한다. 이 엔드포인트는 `OAuth2AuthorizedClientManager.authorize()`를 부르지 않고 `OAuth2AuthorizedClientService`에서 저장된 항목을 직접 조회한다. 그래서 여기를 호출하는 것만으로 액세스 토큰이 갱신되지는 않고, 인증된 세션인데 그 항목이 없으면 서버 보관 여부를 알리는 값 2개가 false인 200이 나올 수도 있다. 어느 경우에도 토큰 원문은 이 응답에 들어가지 않는다. ## 쿠키가 자동으로 붙는 요청을 CSRF 토큰으로 가른다 브라우저는 BFF로 가는 요청마다 세션 쿠키를 자동으로 붙이고 상태를 바꾸는 POST에서도 마찬가지라서, 세션 쿠키만 확인해서는 그 요청이 사용자가 보던 화면에서 시작한 것인지 알 수 없다. CSRF(Cross-Site Request Forgery) 토큰은 그 판단을 서버가 하려고 상태 변경 요청에서만 추가로 대조하는 값이다. POST를 보내기 전에 `/bff/csrf`를 부르면 응답 헤더로 `XSRF-TOKEN` 쿠키를 받는다. ```http label="응답 헤더 — cookie에는 raw 값이 들어간다" HTTP/1.1 200 OK Cache-Control: no-store Pragma: no-cache Set-Cookie: XSRF-TOKEN=; Path=/ ``` 같은 응답의 본문에도 CSRF 정보가 들어 있는데, 본문의 `token`은 쿠키에 들어간 값과 같은 문자열이 아니다. ```json label="응답 본문 — 여기 token은 가려진 값이다" { "headerName": "X-XSRF-TOKEN", "parameterName": "_csrf", "token": "" } ``` :::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()`가 만들면서 원래 값을 그대로 넣는 반면, 본문에 실리는 값은 `XorCsrfTokenRequestAttributeHandler`가 요청 속성으로 노출하는 토큰이라 XOR와 Base64로 가려진 상태다. 그래서 브라우저 쪽 코드는 본문의 `token`을 헤더 값으로 쓰지 않고, 본문에서는 `headerName`만 읽은 다음 실제 값은 `document.cookie`에서 `XSRF-TOKEN`의 raw 값을 꺼내 그 헤더에 넣는다. ```text label="세 자리의 값이 서로 다르다" body.token masked token cookie XSRF-TOKEN raw token X-XSRF-TOKEN raw token ``` 같은 CSRF 자격 증명이 놓이는 자리는 3개이고 그중 같은 문자열인 것은 쿠키와 요청 헤더 2개라, 정상 요청은 세션 쿠키와 `XSRF-TOKEN` 쿠키에 더해 쿠키에서 읽은 값을 담은 `X-XSRF-TOKEN` 헤더를 함께 싣는다. ```http label="CSRF 검증을 통과하는 POST" POST http://localhost:8083/bff/api/preferences Content-Type: application/x-www-form-urlencoded Cookie: AP3_SESSION=; XSRF-TOKEN= X-XSRF-TOKEN: theme=dark ``` 컨트롤러보다 Spring CSRF 필터가 먼저 돈다. 필터가 저장소의 기대값과 제출된 헤더를 비교해서 헤더가 없거나 값이 다르면 컨트롤러는 실행되지 않고 403이 되고, 통과한 요청만 `theme` 파라미터로 묶여 들어간다. 이 나눔을 맞추는 코드가 `SpaCsrfTokenRequestHandler`인데, 기대하는 헤더가 있으면 plain resolver로 제출된 raw 값을 읽고 없으면 XOR resolver 경로를 쓴다. ```json label="컨트롤러가 돌려주는 값" { "updated": true, "theme": "dark", "principal": "regular-user" } ``` :::note 응답 본문의 token을 가리는 것은 BREACH 완화를 위한 처리다. HTTP 응답 압축 크기의 차이를 이용해 응답 안의 비밀값을 추측하는 것을 어렵게 하기 위해 응답에 노출되는 token 형태를 매번 다르게 만든다. ::: ### 쿠키를 빼는 것과 요청을 거절하는 것은 다른 방어선이다 요청 4개로 두 방어선이 각각 어디서 걸리는지 확인했다. | 입력 | 막는 것 | 응답 | |---|---|---| | same-origin, 헤더 없음 | CSRF token | 403 | | same-site 다른 port, 헤더 없음 | CSRF token | 403 | | cross-site POST | SameSite | cookie 누락 | | same-origin, 값 일치 | 통과 | 200 | 같은 출처에서 온 요청과 포트만 다른 same-site 요청에는 세션 쿠키가 그대로 실렸고, `X-XSRF-TOKEN` 헤더가 없었기 때문에 둘 다 403이 됐다. `SameSite`는 쿠키를 cross-site 요청에 실을지 브라우저가 정하는 정책이고, CSRF 토큰은 그 요청을 받아들일지 서버가 정하는 검사다. 포트가 달라도 site 계산상 같은 site가 되는 경우가 있어서, 쿠키가 빠지는 것만으로는 same-site 요청까지 막지 못한다. `127.0.0.1`에서 `localhost`로 보낸 cross-site POST에서는 `AP3_SESSION` 자체가 요청에 실리지 않았고, 이 요청에서 확인한 것은 최종 상태 코드가 아니라 쿠키가 빠졌다는 사실이다. ## 브라우저에서 덜어낸 일이 BFF로 옮겨 온다 브라우저가 토큰을 들지 않게 되면서 BFF가 새로 맡는 일은 8개인데, 그중 지금 구현에 들어 있는 것은 상태 변경 요청의 CSRF 검증 1개다. 재시작 뒤 로그인 유지, 레플리카가 함께 쓰는 세션, 저장한 토큰 암호화, 로그아웃 때 세션과 authorized client 삭제, downstream 오류를 화면 오류로 바꾸는 처리, 타임아웃·재시도·서킷 브레이커, 경로별 인가 7개는 아직 없다. 지금 `HttpSession`과 `OAuth2AuthorizedClientService`는 한 프로세스의 메모리를 쓰기 때문에, 재시작 뒤 로그인을 유지하는 것과 여러 레플리카가 같은 세션을 쓰는 것은 이 상태를 어디에 둘지 정해야 처리할 수 있다. 토큰을 찾는 키도 세션 ID가 아니라 클라이언트 등록 이름과 principal 이름이다. 그래서 같은 사용자가 여러 브라우저 세션에서 로그인하면 그 세션들이 같은 항목을 함께 쓰거나, 나중에 로그인한 쪽이 덮어쓸 수 있다. CSRF 검증을 눈으로 보려고 둔 `theme` 값도 사용자별 저장소에 들어가지 않는다. 컨트롤러 안의 `AtomicReference` 1개에 들어가기 때문에 한 사용자가 `dark`로 바꾸면 같은 프로세스의 다른 사용자도 그 값을 읽고, 재시작하면 기본값 `system`으로 돌아간다. 실제 사용자 상태로 쓰려면 인증된 subject를 키로 하는 저장소와 인가 경계로 옮겨야 한다. ### 커밋된 테스트가 확인하도록 정의한 17개 이 기록에서 확인했다고 적은 것은 마지막 실행 성적표가 아니라 커밋된 자동 테스트가 확인하도록 정의한 계약이다. 항목은 17개이고 그중 10개를 확인했다. 인가 요청에는 `bff-confidential` 클라이언트와 `S256` challenge가 있어야 하고, 로그인 뒤 브라우저 요청 목록에는 Keycloak 토큰 엔드포인트 호출과 8081 직접 호출이 없어야 한다. 쿠키는 `AP3_SESSION`이고 `HttpOnly`와 `SameSite=Lax`가 붙어 있어야 하며 Web Storage는 비어 있어야 한다. `/bff/token-boundary`의 서버 보관 값 2개는 true여야 하고 `/bff/api/me`는 200과 함께 `username`과 기대한 audience를 돌려줘야 한다. CSRF 헤더가 없는 POST는 403, 쿠키에서 읽은 값을 헤더에 넣은 POST는 200, cross-site POST에서는 쿠키가 빠져야 한다. 나머지 7개는 이 계약 밖이라, `theme` 값의 사용자별 격리와 영속성, 여러 레플리카가 함께 쓰는 세션 저장소, 저장한 토큰 암호화, 로그아웃, downstream 401을 어떤 모양으로 전달하는지, 타임아웃과 경로별 인가는 확인하지 않았다. 브라우저가 OAuth 토큰을 받으면 안 되고 백엔드가 화면에 필요한 여러 API를 조합해야 한다면 이 구조를 고른다. OAuth 흐름을 브라우저에서 직접 확인하는 것이 목적이면 SPA 구조가, 브라우저의 보호 자원 서버 직접 호출을 유지해야 한다면 Mediator가 맞는다. 대신 BFF는 요청을 넘겨 주기만 하는 프록시가 아니라 로그인 상태와 토큰을 든 보안 구성요소가 됐고, 화면의 모든 요청이 이곳을 지나므로 지연과 장애 지점도 여기로 모인다. 지금 구현은 그 비용을 단일 인스턴스 메모리와 검증 1개로만 보여 주고 있어서, 세션과 authorized client를 어디에 두고 저장한 토큰을 어떻게 암호화할지는 아직 정하지 않았다.