ADR-0003: BFF CORS, CSRF, and session security model
Status: Accepted
Date: 2026-09-11
Context
The Backend for Frontend (BFF) sits between the Angular PWA and the backend services. It is the only component the browser communicates with directly, which means its security configuration — CORS, CSRF, session management, and authentication entry points — defines the security boundary of the entire application.
The BFF is configured as a Spring Security filter chain with the following concerns:
- CORS — the browser enforces same-origin policy. The BFF must explicitly allow the origins the Angular PWA runs on, the methods it can use, and the headers it can send.
- CSRF — cookie-based sessions are vulnerable to cross-site request forgery. The BFF must validate that state-changing requests originate from the same origin that received the session cookie.
- Session management — authentication is session-based (JSESSIONID cookie), not token-based in the browser. Session creation, validation, and destruction must be handled consistently.
- Authentication entry points — unauthenticated requests to API paths should receive a 401, not be redirected to a login page. The browser-based login flow is initiated explicitly, not via a redirect from an API call.
We considered several approaches within each concern:
-
CORS — permissive vs restricted origins
- Permissive — allow any origin (
AllowedOrigins("***")or a wildcard). Simplest to configure, but offers no protection against malicious sites triggering requests to the BFF. - Restricted to specific origins — only the production PWA domain and the local development origins are allowed. Requires updating the configuration when the origin changes (e.g. onboarding a new deployment environment), but provides meaningful boundary control.
- Permissive — allow any origin (
-
CSRF — stateless token vs synchronizer token vs disabled
- Disabled — simplest, but leaves cookie-based sessions vulnerable to CSRF. Acceptable only if no state-changing requests use cookies for authentication.
- Synchronizer token pattern — Spring Security's default. A token is issued and must be present on state-changing requests. Works well with server-rendered HTML, but requires adaptation for single-page applications.
- Cookie-based double-submit — the CSRF token is exposed as a cookie (via
CookieCsrfTokenRepository.withHttpOnlyFalse()) and the client reads it and sends it back as a header (X-XSRF-TOKEN). This is the model Angular'sHttpClientXsrfModuleexpects. The BFF reads the cookie, the Angular app reads the cookie and sends the value as a header. The BFF then validates the header against the token in the session.
-
Session — stateful vs stateless
- Stateless JWT in the browser — the BFF issues a JWT and the browser stores it (e.g. in memory or local storage). Avoids server-side session state, but exposes the token to the browser and shifts token lifecycle concerns to the client.
- Cookie-based session (JSESSIONID) — the session lives on the server, the browser holds only a session identifier cookie. The BFF is responsible for session creation, validation, and destruction. Aligns with the confidential client model in ADR-0001.
-
Authentication entry points — redirect vs 401
- Redirect to login — unauthenticated requests are redirected to the OAuth2 login page. Suitable for browser navigation, but breaks API clients and creates confusing behavior if an API call triggers a redirect.
- 401 for API paths, redirect for browser paths — API paths (
/api/**,/v2/**) receive a401 Unauthorizedresponse, while browser-facing paths follow the normal OAuth2 login redirect. This requires distinguishing between the two in the exception handling configuration.
Decision
We will use the following security model for the BFF:
CORS
- Only the production PWA origin and the local development origin are allowed. The allowed origins are
https://serendipity.localhostandhttp://localhost:4200. - Allowed methods are
GET,POST,PUT,DELETE, andOPTIONS(the last is required for CORS preflight). - Allowed headers are
Authorization,Cache-Control,Content-Type, andX-XSRF-TOKEN. - Credentials (cookies) are allowed across origins (
allowCredentials = true), which is required for the session cookie to be sent with cross-origin requests.
CSRF
- CSRF protection is enabled.
- The CSRF token is exposed as a cookie (
XSRF-TOKEN) viaCookieCsrfTokenRepository.withHttpOnlyFalse(), so the Angular application can read it. - The Angular application sends the token value back as the
X-XSRF-TOKENheader on state-changing requests. - The BFF validates the header against the token stored in the session.
- Actuator endpoints are excluded from CSRF validation, as they are not state-changing from the application's perspective and may be accessed by monitoring tooling.
- A custom
CsrfTokenRequestHandleris used to ensure the token is resolved and validated as a plain string, avoiding conflicts with Spring Security's default XOR-based token handling.
Session management
- Authentication is session-based. The session identifier is stored in a
JSESSIONIDcookie. - The cookie is created with
withHttpOnlyFalse()so the Angular application cannot read it directly (the CSRF token cookie is the one the application reads — not the session cookie). - Session invalidation on logout deletes the
JSESSIONIDcookie and clears the authentication.
Authentication entry points
- Requests to
/api/**and/v2/**that are unauthenticated receive a401 Unauthorizedresponse (viaHttpStatusEntryPoint). - Browser-facing paths follow the normal OAuth2 login flow and redirect to Keycloak for authentication.
Token relay
- The BFF uses Spring Cloud Gateway's
TokenRelayFilterFunctions.tokenRelay()as a global filter. - This forwards the access token from the session to downstream services (e.g. the Party Service) on behalf of the authenticated user.
- The downstream services receive the token as if it came from the original client, without the browser ever seeing it.
Static assets
- Static assets (
/,/index.html,/*.js,/*.css,/*.ico,/assets/**) and actuator endpoints are permitted without authentication.
Logout
- Logout is initiated via a
GETto/logout. - On logout: the HTTP session is invalidated, authentication is cleared, and the
JSESSIONIDcookie is deleted. - The OIDC logout success handler then redirects to Keycloak to end the global SSO session, with a fallback redirect to the home page if the OIDC context is unavailable.
Production configuration
- The current CORS configuration is tuned for local development (
serendipity.localhostandlocalhost:4200). - Production origins must be configured separately, either via a profile-specific
CorsConfigurationSourcebean or viaapplication-{profile}.yml. - The local development configuration should not ship to production as-is.
Consequences
Positive
- The BFF presents a single, well-defined security boundary. CORS, CSRF, session management, and authentication are all configured in one place.
- CORS is restricted to known origins, reducing the attack surface compared to a permissive configuration.
- CSRF protection is in place for cookie-based sessions without requiring the browser to handle tokens directly.
- The cookie-based session model keeps tokens off the browser, consistent with ADR-0001.
- API paths return 401 for unauthenticated requests, which is the correct behavior for an API and avoids confusing redirects.
- The logout flow ends both the local BFF session and the global Keycloak SSO session.
Negative
- The CORS allowed origins are hard-coded for local development and must be externalized for production. If the production origin is not added before deployment, the PWA will be blocked by the browser.
- CSRF protection adds a header requirement to every state-changing request. The Angular application must be configured to read the
XSRF-TOKENcookie and send it as theX-XSRF-TOKENheader. If this configuration is missing or mismatched, state-changing requests will be rejected. - Session-based authentication means session state lives on the server. In a scaled deployment, sessions must be shared across instances (e.g. via a shared session store) or sticky sessions must be used.
- The custom
CsrfTokenRequestHandleradds complexity. It exists to resolve a specific interaction between Spring Security's default XOR-based token handling and the cookie-based double-submit pattern. If Spring Security's default behavior changes in a future version, this handler may need to be revisited. - Logout via
GETis convenient for the browser but is not strictly RESTful. It works for the current application but should not be relied upon for API clients.
Notes
- This ADR covers the CORS, CSRF, session, authentication entry point, and logout aspects of the BFF's security model. The OAuth 2.0 client model (confidential client, token relay, tokens off the browser) is covered by ADR-0001.
- The
CsrfCookieFilterexists to force the lazyCsrfTokento resolve early so theXSRF-TOKENcookie is written to the response on every request. Without it, the cookie may not be set on the first request, causing the Angular application to fail to send the CSRF header. - The
CookieCsrfTokenRepository.withHttpOnlyFalse()sets the CSRF token cookie as non-HttpOnly so the browser-side JavaScript can read it. This is intentional and required for the double-submit pattern. The session cookie (JSESSIONID) remains HttpOnly by default. - If the Angular application ever switches to using a custom CSRF header name, the
allowedHeaderslist in the CORS configuration must be updated to include it. - If a new backend service is added behind the BFF, the token relay filter already forwards the access token to it. No additional security configuration is required in the BFF for the new service, but the new service must be configured as an OAuth 2.0 resource server.