- generic [ref=f9e3]: - link "본문으로 건너뛰기" [ref=f9e4] [cursor=pointer]: - /url: "#main-content" - banner [ref=f9e5]: - generic [ref=f9e6]: - link "TechLog Studio" [ref=f9e7] [cursor=pointer]: - /url: /studio - text: TechLog - generic [ref=f9e8]: Studio - navigation "Studio 주 탐색" [ref=f9e10]: - link "작업본" [ref=f9e11] [cursor=pointer]: - /url: /studio/documents - link "게시 기록" [ref=f9e12] [cursor=pointer]: - /url: /studio/publications - link "새 문서" [ref=f9e13] [cursor=pointer]: - /url: /studio/documents/new - link "주제·프로젝트" [ref=f9e14] [cursor=pointer]: - /url: /studio/taxonomy - link "릴리즈" [ref=f9e15] [cursor=pointer]: - /url: /studio/releases - link "공개 사이트 보기" [ref=f9e16] [cursor=pointer]: - /url: / - button "로그아웃" [ref=f9e17] - main [ref=f9e18]: - generic [ref=f9e19]: - generic [ref=f9e20]: - region [ref=f9e21]: - generic [ref=f9e22]: - paragraph [ref=f9e23]: CONCEPT · VERSION 5 - heading "문서 편집" [level=1] [ref=f9e24] - paragraph [ref=f9e25]: Cookie로 인증하는 요청에서 CSRF token이 하는 일 - region [ref=f9e26]: - generic [ref=f9e27]: - paragraph [ref=f9e28]: DOCUMENT - heading "기본 정보" [level=2] [ref=f9e29] - generic [ref=f9e30]: - generic [ref=f9e31]: - generic [ref=f9e32]: 제목 - textbox "제목" [ref=f9e33]: Cookie로 인증하는 요청에서 CSRF token이 하는 일 - generic [ref=f9e34]: - generic [ref=f9e35]: slug - textbox "slug" [ref=f9e36]: - /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈) - text: cookie-auth-csrf - generic [ref=f9e37]: - generic [ref=f9e38]: 요약 - textbox "요약" [ref=f9e39]: session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자의 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인이고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다. - generic [aria-hidden] [ref=f9e40]: 목록 카드에는 약 90자까지 보입니다 · 143 / 2000 - generic [ref=f9e41]: - generic [ref=f9e42]: Topic - combobox "Topic" [ref=f9e43]: - option "선택하지 않음" - option "JPA 피드 조회 성능" - option "OAuth/OIDC 인증 경계" [selected] - generic [ref=f9e44]: - generic [ref=f9e45]: Project - combobox "Project" [ref=f9e46]: - option "미지정" - option "Backend Clean Architecture" - option "KeyCloak Patterns" [selected] - option "Liner N + 1문제" - status [ref=f9e47] - group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f9e48]: - generic [ref=f9e50] [cursor=pointer]: - checkbox "SPA" [ref=f9e51] - generic [ref=f9e52]: SPA - generic [ref=f9e53] [cursor=pointer]: - checkbox "Mediator" [ref=f9e54] - generic [ref=f9e55]: Mediator - generic [ref=f9e56] [cursor=pointer]: - checkbox "BFF" [ref=f9e57] - generic [ref=f9e58]: BFF - generic [ref=f9e59] [cursor=pointer]: - checkbox "Forward-Auth" [ref=f9e60] - generic [ref=f9e61]: Forward-Auth - group "관계" [ref=f9e62]: - generic [ref=f9e64]: - generic [ref=f9e65]: - generic [ref=f9e66]: 관계 1 대상 - combobox "관계 1 대상" [ref=f9e67]: - option "대상 선택" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [disabled] - option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" - option "Collection Fetch Join Pagination의 In-memory Paging" - option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Projection 이후에도 1,509행을 읽은 Row Over-fetch" - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" - option "Visibility OR이 Keyset Index를 깨뜨린 문제" - option "Authorization Code와 PKCE가 보호하는 구간" - option "Bearer JWT가 인증된 principal이 되기까지" - option "Cookie로 인증하는 요청에서 CSRF token이 하는 일" - option "브라우저가 credential을 보관하는 위치와 그 성질" - option "Forward-Auth와 Nginx auth_request의 동작" - option "외부 IdP Brokering의 동작" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" [selected] - option "Feed Visibility Query Pattern" - option "Fetch Join · Batch · Projection 선택 기준" - option "Fetch Type과 Fetch Strategy 구분" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP 연동과 Application 인증 구조의 경계" - option "JPA N+1 정량 진단 기준" - option "Keyset Pagination 설계 기준" - option "OAuth/OIDC 인증 패턴 선택 기준" - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "PostgreSQL Query Plan 측정 기준" - option "Public Client와 Confidential Client 구분 기준" - option "Top-N-per-group 선택 기준" - option "실제 동시 트래픽에서도 이 구조가 안정적인가" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "feed_visible을 Production CQRS로 승격할 것인가" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Highlight 없는 FeedItem을 허용할 것인가" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "Round Trip과 Row Volume을 독립 측정할 것인가" - option "BFF가 OAuth Token을 관리하는 조건" - option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" - option "Entity Graph 조회에는 Batch Fetch를 사용한다" - option "Feed Pagination은 Keyset을 사용한다" - option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다." - option "Query Plan은 실제 PostgreSQL에서 측정한다" - option "Query Strategy는 FeedQueryPort 뒤에서 소유한다" - option "현재 Read Model은 CQRS-lite로 유지한다" - option "화면 조회는 Read Projection을 사용한다" - generic [ref=f9e68]: - generic [ref=f9e69]: 관계 1 이유 - textbox "관계 1 이유" [ref=f9e70]: 이 확인이 필요한 구조의 설계 항목이다. - generic [aria-hidden] [ref=f9e71]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다. - generic [ref=f9e72]: - button "위로" [disabled] [ref=f9e73] - button "아래로" [ref=f9e74] - button "삭제" [ref=f9e75] - generic [ref=f9e76]: - generic [ref=f9e77]: - generic [ref=f9e78]: 관계 2 대상 - combobox "관계 2 대상" [ref=f9e79]: - option "대상 선택" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [selected] - option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" - option "Collection Fetch Join Pagination의 In-memory Paging" - option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Projection 이후에도 1,509행을 읽은 Row Over-fetch" - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" - option "Visibility OR이 Keyset Index를 깨뜨린 문제" - option "Authorization Code와 PKCE가 보호하는 구간" - option "Bearer JWT가 인증된 principal이 되기까지" - option "Cookie로 인증하는 요청에서 CSRF token이 하는 일" - option "브라우저가 credential을 보관하는 위치와 그 성질" - option "Forward-Auth와 Nginx auth_request의 동작" - option "외부 IdP Brokering의 동작" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" [disabled] - option "Feed Visibility Query Pattern" - option "Fetch Join · Batch · Projection 선택 기준" - option "Fetch Type과 Fetch Strategy 구분" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP 연동과 Application 인증 구조의 경계" - option "JPA N+1 정량 진단 기준" - option "Keyset Pagination 설계 기준" - option "OAuth/OIDC 인증 패턴 선택 기준" - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "PostgreSQL Query Plan 측정 기준" - option "Public Client와 Confidential Client 구분 기준" - option "Top-N-per-group 선택 기준" - option "실제 동시 트래픽에서도 이 구조가 안정적인가" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "feed_visible을 Production CQRS로 승격할 것인가" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Highlight 없는 FeedItem을 허용할 것인가" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "Round Trip과 Row Volume을 독립 측정할 것인가" - option "BFF가 OAuth Token을 관리하는 조건" - option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" - option "Entity Graph 조회에는 Batch Fetch를 사용한다" - option "Feed Pagination은 Keyset을 사용한다" - option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다." - option "Query Plan은 실제 PostgreSQL에서 측정한다" - option "Query Strategy는 FeedQueryPort 뒤에서 소유한다" - option "현재 Read Model은 CQRS-lite로 유지한다" - option "화면 조회는 Read Projection을 사용한다" - generic [ref=f9e80]: - generic [ref=f9e81]: 관계 2 이유 - textbox "관계 2 이유" [ref=f9e82]: 이 동작을 실제로 재현한 기록이다. - generic [aria-hidden] [ref=f9e83]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다. - generic [ref=f9e84]: - button "위로" [ref=f9e85] - button "아래로" [ref=f9e86] - button "삭제" [ref=f9e87] - generic [ref=f9e88]: - generic [ref=f9e89]: - generic [ref=f9e90]: 관계 3 대상 - combobox "관계 3 대상" [ref=f9e91]: - option "대상 선택" - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [disabled] - option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" - option "Collection Fetch Join Pagination의 In-memory Paging" - option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Projection 이후에도 1,509행을 읽은 Row Over-fetch" - option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" - option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" - option "Visibility OR이 Keyset Index를 깨뜨린 문제" - option "Authorization Code와 PKCE가 보호하는 구간" - option "Bearer JWT가 인증된 principal이 되기까지" - option "Cookie로 인증하는 요청에서 CSRF token이 하는 일" - option "브라우저가 credential을 보관하는 위치와 그 성질" - option "Forward-Auth와 Nginx auth_request의 동작" - option "외부 IdP Brokering의 동작" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" [disabled] - option "Feed Visibility Query Pattern" - option "Fetch Join · Batch · Projection 선택 기준" - option "Fetch Type과 Fetch Strategy 구분" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP 연동과 Application 인증 구조의 경계" - option "JPA N+1 정량 진단 기준" - option "Keyset Pagination 설계 기준" - option "OAuth/OIDC 인증 패턴 선택 기준" - option "OAuth Token과 Application Session을 구분하는 기준" [selected] - option "PostgreSQL Query Plan 측정 기준" - option "Public Client와 Confidential Client 구분 기준" - option "Top-N-per-group 선택 기준" - option "실제 동시 트래픽에서도 이 구조가 안정적인가" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - option "feed_visible을 Production CQRS로 승격할 것인가" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Highlight 없는 FeedItem을 허용할 것인가" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "Round Trip과 Row Volume을 독립 측정할 것인가" - option "BFF가 OAuth Token을 관리하는 조건" - option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" - option "Entity Graph 조회에는 Batch Fetch를 사용한다" - option "Feed Pagination은 Keyset을 사용한다" - option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다." - option "Query Plan은 실제 PostgreSQL에서 측정한다" - option "Query Strategy는 FeedQueryPort 뒤에서 소유한다" - option "현재 Read Model은 CQRS-lite로 유지한다" - option "화면 조회는 Read Projection을 사용한다" - generic [ref=f9e92]: - generic [ref=f9e93]: 관계 3 이유 - textbox "관계 3 이유" [ref=f9e94]: session cookie와 CSRF token은 서로 다른 값이다. - generic [aria-hidden] [ref=f9e95]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다. - generic [ref=f9e96]: - button "위로" [ref=f9e97] - button "아래로" [disabled] [ref=f9e98] - button "삭제" [ref=f9e99] - button "관계 추가" [ref=f9e100] - region [ref=f9e101]: - generic [ref=f9e102]: - paragraph [ref=f9e103]: CONCEPT - heading "개념" [level=2] [ref=f9e104] - generic [ref=f9e105]: - generic [ref=f9e106]: - generic [ref=f9e107]: 기준 버전 - textbox "기준 버전 “Kubernetes 1.31” 처럼 무엇을 보고 썼는지. 비워도 됩니다." [ref=f9e108]: Spring Security 6 CSRF · AP3 BFF 구성 - generic [ref=f9e109]: “Kubernetes 1.31” 처럼 무엇을 보고 썼는지. 비워도 됩니다. - generic [ref=f9e110]: - generic [ref=f9e111]: 본문 Markdown - group "Markdown 삽입" [ref=f9e112]: - button "코드" [ref=f9e113] [cursor=pointer] - button "표" [ref=f9e114] [cursor=pointer] - button "목록" [ref=f9e115] [cursor=pointer] - textbox "본문 Markdown" [ref=f9e116]: "## cookie가 credential이 되면 생기는 일 브라우저가 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다. 이 cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다. `GET /bff/api/me`만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다. ## token을 받아 오는 요청 브라우저가 먼저 CSRF material을 요청한다. ```http label=\"CSRF token 요청\" GET http://localhost:8083/bff/csrf Accept: application/json Cookie: AP3_SESSION= ``` `CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` cookie를 path `/`에 만든다. controller는 다음 JSON을 반환한다. ```json label=\"CsrfController가 반환하는 JSON\" { \"headerName\": \"X-XSRF-TOKEN\", \"parameterName\": \"_csrf\", \"token\": \"\" } ``` :::evidence key=\"ap3-csrf-boundary-971df81c\" alt=\"BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.\" caption=\" \" zoom=\"true\" ::: ## body의 token과 cookie의 값은 다르다 여기가 이 구조에서 가장 헷갈리는 지점이다. | 위치 | 값 | |---|---| | 응답 body의 `token` | XOR와 Base64로 mask된 값 | | `XSRF-TOKEN` cookie | raw 값 | | POST의 `X-XSRF-TOKEN` 헤더 | cookie와 같은 raw 값 | `XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서 `headerName`만 읽고, 실제 값은 `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 쓴다. `SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 XOR resolver 경로를 쓴다. 응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다. 노출 값과 제출 값이 다를 수 있다는 것을 클라이언트 코드가 알아야 한다. ## 검증이 controller보다 먼저 일어난다 정상 상태 변경 요청은 다음과 같다. ```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 filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다. ## SameSite와 CSRF token은 역할이 다르다 | | SameSite | CSRF token | |---|---|---| | 누가 판단하나 | 브라우저 | 서버 | | 무엇을 정하나 | cookie를 보낼지 | 요청을 받아들일지 | | 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 | 두 방어선은 서로를 대신하지 못한다. port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 막지 않는 요청에도 CSRF 검증이 필요하다. 네 가지 입력을 나란히 두면 어느 방어선이 작동하는지 갈린다. | 입력 | cookie 동작 | CSRF 동작 | 결과 | |---|---|---|---| | same-origin, CSRF 헤더 없음 | session cookie 붙음 | token 부재로 거부 | 403 | | same-origin, raw cookie와 헤더 일치 | session cookie 붙음 | token 일치 | 200 | | 다른 port지만 same-site, 헤더 없음 | cookie가 붙을 수 있음 | token 부재로 거부 | 403 | | cross-site POST | SameSite=Lax로 cookie 제외 | 이 지점 이후는 고정하지 않음 | cookie omission이 확인 지점 | 마지막 줄에서 확인하는 것은 최종 status가 아니라 cookie가 빠졌는지다. ## CSRF가 XSS를 대신하지 않는다 브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는 `XSRF-TOKEN`도 같이 읽을 수 있다. 이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다." - generic [ref=f9e117]: “##” 소제목이 목차가 됩니다. 아키텍처 도식은 아래에서 삽입하세요. - group [ref=f9e118]: - paragraph [ref=f9e119]: EVIDENCE - heading "본문에 Asset 삽입" [level=3] [ref=f9e120] - paragraph [ref=f9e121]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다. - generic [ref=f9e122]: - generic [ref=f9e123]: - generic [ref=f9e124]: 업로드 종류 - combobox "업로드 종류" [ref=f9e125]: - option "이미지" [selected] - option "다이어그램" - option "첨부파일" - button "Asset 업로드" [ref=f9e126] - generic [ref=f9e127]: - search [ref=f9e128]: - generic [ref=f9e129]: Asset 검색 - generic [ref=f9e130]: - searchbox "Asset 검색" [ref=f9e131] - button "검색" [ref=f9e132] - generic [ref=f9e133]: - checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f9e134] - generic [ref=f9e135]: 삽입할 때 크게 보기 허용 - status [ref=f9e136]: 삽입할 수 있는 Asset 24개 - list [ref=f9e137]: - listitem [ref=f9e138]: - button "ap4-edge-trust-architecture-1a916e10" [ref=f9e139] - button "삭제" [ref=f9e140] - listitem [ref=f9e141]: - button "ap3-bff-session-flow-1b005e15" [ref=f9e142] - button "삭제" [ref=f9e143] - listitem [ref=f9e144]: - button "ap3-bff-architecture-a27ea91c" [ref=f9e145] - button "삭제" [ref=f9e146] - listitem [ref=f9e147]: - button "ap2-mediator-handoff-flow-efe7039c" [ref=f9e148] - button "삭제" [ref=f9e149] - listitem [ref=f9e150]: - button "ap2-mediator-architecture-c95ed25f" [ref=f9e151] - button "삭제" [ref=f9e152] - listitem [ref=f9e153]: - button "projection-row-over-fetch-f2b1943b" [ref=f9e154] - button "삭제" [ref=f9e155] - listitem [ref=f9e156]: - button "cartesian-row-multiplication-dce2e166" [ref=f9e157] - button "삭제" [ref=f9e158] - listitem [ref=f9e159]: - button "eager-lazy-query-sequence-47c12bda" [ref=f9e160] - button "삭제" [ref=f9e161] - listitem [ref=f9e162]: - button "ap3-bff-session-flow-a8dfff6f" [ref=f9e163] - button "삭제" [ref=f9e164] - listitem [ref=f9e165]: - button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f9e166] - button "삭제" [ref=f9e167] - listitem [ref=f9e168]: - button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f9e169] - button "삭제" [ref=f9e170] - listitem [ref=f9e171]: - button "ap3-csrf-boundary-971df81c" [ref=f9e172] - button "삭제" [ref=f9e173] - listitem [ref=f9e174]: - button "login-api-phase-split-3e354274" [ref=f9e175] - button "삭제" [ref=f9e176] - listitem [ref=f9e177]: - button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f9e178] - button "삭제" [ref=f9e179] - listitem [ref=f9e180]: - button "ap1-direct-architecture-0adf4199" [ref=f9e181] - button "삭제" [ref=f9e182] - listitem [ref=f9e183]: - button "nplus1-query-fanout-644febe6" [ref=f9e184] - button "삭제" [ref=f9e185] - listitem [ref=f9e186]: - button "ap4-edge-trust-1cff2399" [ref=f9e187] - button "삭제" [ref=f9e188] - listitem [ref=f9e189]: - button "ap3-csrf-split-501dd1f7" [ref=f9e190] - button "삭제" [ref=f9e191] - listitem [ref=f9e192]: - button "ap3-bff-custody-82fa18bd" [ref=f9e193] - button "삭제" [ref=f9e194] - listitem [ref=f9e195]: - button "ap2-split-custody-779cb791" [ref=f9e196] - button "삭제" [ref=f9e197] - listitem [ref=f9e198]: - button "ap1-custody-v3-6e0376d2" [ref=f9e199] - button "삭제" [ref=f9e200] - listitem [ref=f9e201]: - button "ap1-custody-v2-e110bd98" [ref=f9e202] - button "삭제" [ref=f9e203] - listitem [ref=f9e204]: - button "ap1-credential-custody-f5e0c027" [ref=f9e205] - button "삭제" [ref=f9e206] - listitem [ref=f9e207]: - button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f9e208] - button "삭제" [ref=f9e209] - region [ref=f9e210]: - generic [ref=f9e211]: - paragraph [ref=f9e212]: LIVE - heading "즉시 미리보기" [level=2] [ref=f9e213] - generic [ref=f9e216]: - generic [ref=f9e217]: - navigation "문서 경로" [ref=f9e218]: - link "동작 원리" [ref=f9e219] [cursor=pointer]: - /url: /explore/concepts - generic [aria-hidden] [ref=f9e220]: / - generic [ref=f9e221]: OAuth/OIDC 인증 경계 - generic [aria-hidden] [ref=f9e222]: / - link "KeyCloak Patterns" [ref=f9e223] [cursor=pointer]: - /url: /projects/keycloak-patterns - heading "Cookie로 인증하는 요청에서 CSRF token이 하는 일" [level=1] [ref=f9e224] - paragraph [ref=f9e225]: session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자의 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인이고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다. - generic [ref=f9e226]: - generic [ref=f9e227]: - term [ref=f9e228]: 기준 - definition [ref=f9e229]: - paragraph [ref=f9e230]: Spring Security 6 CSRF · AP3 BFF 구성 - generic [ref=f9e231]: - term [ref=f9e232]: 기록 - definition [ref=f9e233]: 게시 게시 전 - group [ref=f9e235]: - generic "목차 · SameSite와 CSRF token은 역할이 다르다" [ref=f9e236] [cursor=pointer] - article [ref=f9e238]: - region [ref=f9e239]: - heading [level=2] [ref=f9e240]: - link "cookie가 credential이 되면 생기는 일 바로가기" [ref=f9e241] [cursor=pointer]: - /url: "#cookie가-credential이-되면-생기는-일" - text: cookie가 credential이 되면 생기는 일 - generic [aria-hidden] [ref=f9e242]: "#" - paragraph [ref=f9e243]: 브라우저가 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다. - paragraph [ref=f9e244]: - text: 이 cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다. - code [ref=f9e245]: GET /bff/api/me - text: 만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다. - region [ref=f9e246]: - heading [level=2] [ref=f9e247]: - link "token을 받아 오는 요청 바로가기" [ref=f9e248] [cursor=pointer]: - /url: "#token을-받아-오는-요청" - text: token을 받아 오는 요청 - generic [aria-hidden] [ref=f9e249]: "#" - paragraph [ref=f9e250]: 브라우저가 먼저 CSRF material을 요청한다. - figure "HTTP ·CSRF token 요청 코드 복사" [ref=f9e251]: - generic [ref=f9e252]: - generic [ref=f9e253]: HTTP - generic [ref=f9e254]: ·CSRF token 요청 - button "코드 복사" [ref=f9e255] [cursor=pointer]: 복사 - region "CSRF token 요청 코드" [ref=f9e256]: - code [ref=f9e257]: "GET http://localhost:8083/bff/csrf Accept: application/json Cookie: AP3_SESSION=" - paragraph [ref=f9e259]: - code [ref=f9e260]: CookieCsrfTokenRepository.withHttpOnlyFalse() - text: 는 JavaScript가 읽을 수 있는 - code [ref=f9e261]: XSRF-TOKEN - text: cookie를 path - code [ref=f9e262]: / - text: 에 만든다. controller는 다음 JSON을 반환한다. - figure "JSON ·CsrfController가 반환하는 JSON 코드 복사" [ref=f9e263]: - generic [ref=f9e264]: - generic [ref=f9e265]: JSON - generic [ref=f9e266]: ·CsrfController가 반환하는 JSON - button "코드 복사" [ref=f9e267] [cursor=pointer]: 복사 - region "CsrfController가 반환하는 JSON 코드" [ref=f9e268]: - code [ref=f9e269]: "{ \"headerName\": \"X-XSRF-TOKEN\", \"parameterName\": \"_csrf\", \"token\": \"\" }" - figure [ref=f9e271]: - button "ap3-csrf-boundary-971df81c 이미지 크게 보기" [ref=f9e272]: - img "BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름." [ref=f9e273] - generic [ref=f9e274]: 크게 보기 - generic [ref=f9e275]: BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름. - region [ref=f9e276]: - heading [level=2] [ref=f9e277]: - link "body의 token과 cookie의 값은 다르다 바로가기" [ref=f9e278] [cursor=pointer]: - /url: "#body의-token과-cookie의-값은-다르다" - text: body의 token과 cookie의 값은 다르다 - generic [aria-hidden] [ref=f9e279]: "#" - paragraph [ref=f9e280]: 여기가 이 구조에서 가장 헷갈리는 지점이다. - region "표" [ref=f9e281]: - table [ref=f9e282]: - caption [ref=f9e283] - rowgroup [ref=f9e284]: - row [ref=f9e285]: - columnheader "위치" [ref=f9e286] - columnheader "값" [ref=f9e287] - rowgroup [ref=f9e288]: - row [ref=f9e289]: - cell [ref=f9e290]: - text: 응답 body의 - code [ref=f9e291]: token - cell "XOR와 Base64로 mask된 값" [ref=f9e292] - row [ref=f9e293]: - cell [ref=f9e294]: - code [ref=f9e295]: XSRF-TOKEN - text: cookie - cell "raw 값" [ref=f9e296] - row [ref=f9e297]: - cell [ref=f9e298]: - text: POST의 - code [ref=f9e299]: X-XSRF-TOKEN - text: 헤더 - cell "cookie와 같은 raw 값" [ref=f9e300] - paragraph [ref=f9e301]: - code [ref=f9e302]: XorCsrfTokenRequestAttributeHandler - text: 가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서 - code [ref=f9e303]: headerName - text: 만 읽고, 실제 값은 - code [ref=f9e304]: document.cookie - text: 에서 raw - code [ref=f9e305]: XSRF-TOKEN - text: 을 찾아 쓴다. - paragraph [ref=f9e306]: - code [ref=f9e307]: SpaCsrfTokenRequestHandler - text: 가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 XOR resolver 경로를 쓴다. - paragraph [ref=f9e308]: - text: 응답 JSON의 - code [ref=f9e309]: token - text: 을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다. 노출 값과 제출 값이 다를 수 있다는 것을 클라이언트 코드가 알아야 한다. - region [ref=f9e310]: - heading [level=2] [ref=f9e311]: - link "검증이 controller보다 먼저 일어난다 바로가기" [ref=f9e312] [cursor=pointer]: - /url: "#검증이-controller보다-먼저-일어난다" - text: 검증이 controller보다 먼저 일어난다 - generic [aria-hidden] [ref=f9e313]: "#" - paragraph [ref=f9e314]: 정상 상태 변경 요청은 다음과 같다. - figure "HTTP ·CSRF 검증을 통과하는 POST 코드 복사" [ref=f9e315]: - generic [ref=f9e316]: - generic [ref=f9e317]: HTTP - generic [ref=f9e318]: ·CSRF 검증을 통과하는 POST - button "코드 복사" [ref=f9e319] [cursor=pointer]: 복사 - region "CSRF 검증을 통과하는 POST 코드" [ref=f9e320]: - code [ref=f9e321]: "POST http://localhost:8083/bff/api/preferences Content-Type: application/x-www-form-urlencoded Cookie: AP3_SESSION=; XSRF-TOKEN= X-XSRF-TOKEN: theme=dark" - paragraph [ref=f9e323]: Spring CSRF filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다. - region [ref=f9e324]: - heading [level=2] [ref=f9e325]: - link "SameSite와 CSRF token은 역할이 다르다 바로가기" [ref=f9e326] [cursor=pointer]: - /url: "#samesite와-csrf-token은-역할이-다르다" - text: SameSite와 CSRF token은 역할이 다르다 - generic [aria-hidden] [ref=f9e327]: "#" - region "표" [ref=f9e328]: - table [ref=f9e329]: - caption [ref=f9e330] - rowgroup [ref=f9e331]: - row [ref=f9e332]: - columnheader [ref=f9e333] - columnheader "SameSite" [ref=f9e334] - columnheader "CSRF token" [ref=f9e335] - rowgroup [ref=f9e336]: - row [ref=f9e337]: - cell "누가 판단하나" [ref=f9e338] - cell "브라우저" [ref=f9e339] - cell "서버" [ref=f9e340] - row [ref=f9e341]: - cell "무엇을 정하나" [ref=f9e342] - cell "cookie를 보낼지" [ref=f9e343] - cell "요청을 받아들일지" [ref=f9e344] - row [ref=f9e345]: - cell "언제 작동하나" [ref=f9e346] - cell "요청을 만들 때" [ref=f9e347] - cell "요청을 처리할 때" [ref=f9e348] - paragraph [ref=f9e349]: 두 방어선은 서로를 대신하지 못한다. port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 막지 않는 요청에도 CSRF 검증이 필요하다. - paragraph [ref=f9e350]: 네 가지 입력을 나란히 두면 어느 방어선이 작동하는지 갈린다. - region "표" [ref=f9e351]: - table [ref=f9e352]: - caption [ref=f9e353] - rowgroup [ref=f9e354]: - row [ref=f9e355]: - columnheader "입력" [ref=f9e356] - columnheader "cookie 동작" [ref=f9e357] - columnheader "CSRF 동작" [ref=f9e358] - columnheader "결과" [ref=f9e359] - rowgroup [ref=f9e360]: - row [ref=f9e361]: - cell "same-origin, CSRF 헤더 없음" [ref=f9e362] - cell "session cookie 붙음" [ref=f9e363] - cell "token 부재로 거부" [ref=f9e364] - cell "403" [ref=f9e365] - row [ref=f9e366]: - cell "same-origin, raw cookie와 헤더 일치" [ref=f9e367] - cell "session cookie 붙음" [ref=f9e368] - cell "token 일치" [ref=f9e369] - cell "200" [ref=f9e370] - row [ref=f9e371]: - cell "다른 port지만 same-site, 헤더 없음" [ref=f9e372] - cell "cookie가 붙을 수 있음" [ref=f9e373] - cell "token 부재로 거부" [ref=f9e374] - cell "403" [ref=f9e375] - row [ref=f9e376]: - cell "cross-site POST" [ref=f9e377] - cell "SameSite=Lax로 cookie 제외" [ref=f9e378] - cell "이 지점 이후는 고정하지 않음" [ref=f9e379] - cell "cookie omission이 확인 지점" [ref=f9e380] - paragraph [ref=f9e381]: 마지막 줄에서 확인하는 것은 최종 status가 아니라 cookie가 빠졌는지다. - region [ref=f9e382]: - heading [level=2] [ref=f9e383]: - link "CSRF가 XSS를 대신하지 않는다 바로가기" [ref=f9e384] [cursor=pointer]: - /url: "#csrf가-xss를-대신하지-않는다" - text: CSRF가 XSS를 대신하지 않는다 - generic [aria-hidden] [ref=f9e385]: "#" - paragraph [ref=f9e386]: - text: 브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는 - code [ref=f9e387]: XSRF-TOKEN - text: 도 같이 읽을 수 있다. - paragraph [ref=f9e388]: 이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다. - region [ref=f9e389]: - paragraph [ref=f9e390]: Next - heading "다음에 읽을 것" [level=2] [ref=f9e391] - list [ref=f9e392]: - listitem [ref=f9e393]: - link "적용 기준 BFF 인증 구조 설계 기준" [ref=f9e394] [cursor=pointer]: - /url: /references/bff-authentication-design-criteria - generic [ref=f9e395]: 적용 기준 - generic [ref=f9e396]: - strong [ref=f9e397]: BFF 인증 구조 설계 기준 - paragraph [aria-hidden] [ref=f9e398]: 이 확인이 필요한 구조의 설계 항목이다. - generic [aria-hidden] [ref=f9e399]: ↗ - listitem [ref=f9e400]: - link "검증 기록 Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [ref=f9e401] [cursor=pointer]: - /url: /cases/bff-session-csrf-responsibility - generic [ref=f9e402]: 검증 기록 - generic [ref=f9e403]: - strong [ref=f9e404]: Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정 - paragraph [aria-hidden] [ref=f9e405]: 이 동작을 실제로 재현한 기록이다. - generic [aria-hidden] [ref=f9e406]: ↗ - listitem [ref=f9e407]: - link "적용 기준 OAuth Token과 Application Session을 구분하는 기준" [ref=f9e408] [cursor=pointer]: - /url: /references/oauth-token-application-session-boundary - generic [ref=f9e409]: 적용 기준 - generic [ref=f9e410]: - strong [ref=f9e411]: OAuth Token과 Application Session을 구분하는 기준 - paragraph [aria-hidden] [ref=f9e412]: session cookie와 CSRF token은 서로 다른 값이다. - generic [aria-hidden] [ref=f9e413]: ↗ - complementary [ref=f9e414]: - heading "작업 상태" [level=2] [ref=f9e415] - status "편집 상태" [ref=f9e416]: 저장됨 - generic [ref=f9e417]: - generic [ref=f9e418]: - term [ref=f9e419]: 저장 버전 - definition [ref=f9e420]: "5" - generic [ref=f9e421]: - term [ref=f9e422]: 종류 - definition [ref=f9e423]: 동작 원리 - paragraph [ref=f9e424]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다. - generic [ref=f9e425]: - button "저장" [disabled] [ref=f9e426] - button "게시" [ref=f9e427] - paragraph [ref=f9e428]: 버전 5으로 저장했습니다.