P4 — BFF Security Patterns
P4 — BFF Security Patterns
Phase 4 of the BFF Architecture GSD research project. This note documents the full security implementation for a Spring Boot + Spring Cloud Gateway BFF serving an Angular SPA: OAuth2 client configuration, Redis-backed session, token relay, CSRF, CORS, rate limiting, and the key security mistakes to avoid.
SEC-01: BFF as OAuth2 Confidential Client
Why the BFF Can Hold a client_secret but a SPA Cannot
An OAuth2 confidential client is one that can securely hold credentials — specifically, a client_secret registered with the IdP. Confidentiality requires:
- The secret is stored outside the browser (the attacker's execution environment)
- The secret is never transmitted to the browser
A BFF is a server-side process meeting both conditions. A SPA runs in the browser and meets neither — any secret bundled into a SPA's JavaScript can be extracted by any user who opens DevTools.
This distinction is fundamental. The BFF's confidential client status enables:
- Server-to-server token exchange (the IdP trusts the BFF can authenticate itself)
- Safe
client_secretstorage (environment variable, secrets manager) refresh_tokenissuance (IdPs are more willing to issue refresh tokens to confidential clients)
For the full pattern, see OAuth2-BFF-Pattern and BFF-For-SPA.
Spring Boot Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>This single dependency brings in:
ReactiveClientRegistrationRepository— auto-configured fromapplication.ymlServerOAuth2AuthorizedClientRepository— stores authorized clients (tokens) per userReactiveOAuth2AuthorizedClientManager— manages token acquisition and refreshServerOAuth2LoginAuthenticationTokenprocessing- PKCE generation and verification (via
OAuth2AuthorizationRequestResolver)
application.yml Client Registration for Keycloak
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: bff-client
client-secret: ${KEYCLOAK_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope: openid, profile, email, offline_access
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: https://auth.example.com/realms/myrealm
# Spring Boot auto-discovers all endpoints from:
# https://auth.example.com/realms/myrealm/.well-known/openid-configurationKey points:
offline_accessscope is required to receive arefresh_tokenfrom Keycloak{baseUrl}and{registrationId}are Spring Security template variables — they resolve at runtimeissuer-uritriggers OIDC discovery; Spring Boot fetches the JWKS URI, token endpoint, and authorization endpoint automatically- The
ReactiveClientRegistrationRepositorybean is auto-configured from this YAML — no@Beandeclaration needed
SEC-02: Token Storage — Redis-Backed Session
Why Redis Is Required
Without a shared session store, a BFF scaled to N instances would lose token data when a request hits a different instance than the one that performed login. Sticky sessions (load balancer affinity) are fragile and prevent true horizontal scaling.
Redis solves this: all BFF instances read and write to the same session store. Any instance can serve any request. Session data (including the stored OAuth2AuthorizedClient containing the tokens) is durable and shared.
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>Session Configuration
spring:
session:
store-type: redis
timeout: 3600s
redis:
namespace: bff:session
data:
redis:
host: ${REDIS_HOST:localhost}
port: 6379
password: ${REDIS_PASSWORD:}
ssl:
enabled: ${REDIS_SSL:false}namespace: bff:session— all session keys in Redis are prefixed withbff:session:— important when the Redis instance is shared with other applicationstimeout: 3600s— absolute session timeout; sliding window can be configured separatelyssl.enabled— must betruein production; Redis connections without TLS expose tokens in transit
Cookie Configuration: Enforcing httpOnly, Secure, SameSite
Spring Session's default cookie settings can be tightened using CookieServerSessionIdResolver:
@Configuration
public class SessionCookieConfig {
@Bean
public WebSessionIdResolver webSessionIdResolver() {
CookieWebSessionIdResolver resolver = new CookieWebSessionIdResolver();
resolver.setCookieName("SESSION");
resolver.addCookieInitializer(builder -> builder
.httpOnly(true) // JavaScript cannot read the session cookie
.secure(true) // only sent over HTTPS
.sameSite("Lax") // blocks cross-site POST; allows top-level GET redirects (required for OAuth2 redirect)
.path("/")
);
return resolver;
}
}Note: SameSite=Strict would break the OAuth2 redirect flow — the IdP redirect back to the BFF is a cross-site navigation that must carry the session cookie for PKCE verifier retrieval. Lax is the correct value.
What Is Stored in the Session
Spring Security stores the following in the Redis-backed session:
| Key | Value | Purpose |
|---|---|---|
SPRING_SECURITY_CONTEXT | SecurityContext with OAuth2AuthenticationToken | Current user identity |
org.springframework.security.oauth2.client:authorized-client:keycloak:<username> | OAuth2AuthorizedClient (access_token, refresh_token, expires_at) | Tokens for relay |
SPRING_SECURITY_SAVED_REQUEST | Original request URL before login redirect | Post-login redirect destination |
| OAuth2 PKCE state | code_verifier, state parameter | PKCE binding during authorization flow |
SEC-03: Token Relay to Downstream Services
For full implementation detail, see Token-Relay-Pattern. This section documents the route-level configuration.
Option A: TokenRelay= Gateway Filter (Recommended for Proxy Routes)
The TokenRelayGatewayFilterFactory reads the OAuth2AuthorizedClient from the reactive security context and injects the access_token as an Authorization: Bearer header on the proxied request.
spring:
cloud:
gateway:
routes:
- id: orders-service
uri: lb://orders-service
predicates:
- Path=/api/orders/**
filters:
- SaveSession # flush session to Redis BEFORE forwarding
- TokenRelay= # inject Bearer token into proxied request
- RewritePath=/api/orders/(?<segment>.*), /$\{segment}
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
filters:
- SaveSession
- TokenRelay=
- StripPrefix=2SaveSession before TokenRelay= is required to prevent a race condition: without it, a newly created session may not yet be flushed to Redis when the route executes the relay.
Option B: WebClient with OAuth2 Filter (For Custom Aggregation Logic)
When BFF routes involve programmatic request composition (fan-out to multiple services, result merging) rather than simple proxying, use ServerOAuth2AuthorizedClientExchangeFilterFunction:
@Bean
public WebClient downstreamWebClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager) {
ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2Filter =
new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth2Filter.setDefaultOAuth2AuthorizedClient(true);
return WebClient.builder()
.filter(oauth2Filter)
.baseUrl("http://orders-service")
.build();
}setDefaultOAuth2AuthorizedClient(true) causes the filter to automatically use the currently authenticated user's authorized client — no explicit token extraction is needed at the call site.
SEC-04: Complete SecurityWebFilterChain
The following is a production-grade Spring Security 6.x configuration for a BFF serving an Angular SPA. Every component is intentional — the comments explain why each choice matters.
@Configuration
@EnableWebFluxSecurity
public class BffSecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.authorizeExchange(exchanges -> exchanges
// Health and info endpoints must be publicly accessible for load balancer probes
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
// OAuth2 and logout endpoints handled by Spring Security — must be open
.pathMatchers("/oauth2/**", "/login/**", "/logout").permitAll()
// All other endpoints require authentication
.anyExchange().authenticated()
)
// Enable Authorization Code Flow with PKCE — Spring Security handles all PKCE mechanics
.oauth2Login(Customizer.withDefaults())
// OIDC back-channel logout or RP-initiated logout
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessHandler(oidcLogoutSuccessHandler())
)
// CSRF MUST be enabled for browser-facing BFF — do NOT disable
// CookieServerCsrfTokenRepository sets XSRF-TOKEN cookie (non-httpOnly)
// Angular's HttpClientModule reads this cookie and sends X-XSRF-TOKEN header automatically
.csrf(csrf -> csrf
.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaServerCsrfTokenRequestHandler())
)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.build();
}
@Bean
ServerLogoutSuccessHandler oidcLogoutSuccessHandler() {
OidcClientInitiatedServerLogoutSuccessHandler handler =
new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository);
// Redirect to Angular app after IdP logout
handler.setPostLogoutRedirectUri("{baseUrl}");
return handler;
}
@Autowired
ReactiveClientRegistrationRepository clientRegistrationRepository;
}Critical Note on CSRF
Do NOT disable CSRF for a browser-facing BFF. This is the single most common security mistake in BFF implementations.
The rationale for disabling CSRF is usually "SPAs send JSON and set Content-Type: application/json" — but content-type checks are not a reliable CSRF defense. The correct defence is:
SameSite=Laxon the session cookie (blocks cross-site POST)- Spring Security CSRF filter with
CookieServerCsrfTokenRepository - Angular reads the
XSRF-TOKENcookie and sendsX-XSRF-TOKENheader
CookieServerCsrfTokenRepository.withHttpOnlyFalse() sets the XSRF-TOKEN cookie as readable by JavaScript (non-httpOnly) — this is intentional and required so Angular's HttpClientModule can read and relay the token. The session cookie remains httpOnly.
SpaServerCsrfTokenRequestHandler (available in Spring Security 6.x) supports both header-based and form-based CSRF token submission, which is required for Angular's pattern.
SEC-05: CORS Configuration for Angular
A browser-facing BFF must explicitly configure CORS to allow the Angular development server (localhost:4200) and the production domain. Incorrectly configured CORS is one of the most common causes of Angular-BFF integration failures.
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:4200", // Angular CLI dev server
"https://app.example.com" // Production Angular origin
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
config.setAllowedHeaders(List.of("*"));
// allowCredentials MUST be true for cookie-based sessions
// Without this, browsers strip the session cookie from cross-origin requests
config.setAllowCredentials(true);
// Cache preflight response for 1 hour — reduces OPTIONS requests in prod
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}Why allowCredentials(true) Is Required
Session cookies are credentials. The browser's CORS policy strips cookies from cross-origin requests unless the server explicitly sets Access-Control-Allow-Credentials: true and the client sets withCredentials: true (or Angular's HttpClientModule does so).
Why allowedOrigins("*") Does Not Work With Credentials
The browser rejects Access-Control-Allow-Credentials: true combined with Access-Control-Allow-Origin: *. This is a browser-enforced security constraint, not a Spring bug. Specific origins must be listed.
Angular HttpClientModule Configuration
Angular must be configured to send cookies on cross-origin requests:
// In AppModule or provideHttpClient()
HttpClientModule // automatically sends credentials when withCredentials is set per request
// OR configure globally (recommended for BFF pattern):
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([
(req, next) => next(req.clone({ withCredentials: true }))
]))
]
};SEC-06: Rate Limiting
Rate limiting at the BFF layer protects downstream services from abuse, prevents credential stuffing on the login endpoint, and enforces fair-use policies per authenticated user.
Spring Cloud Gateway provides RequestRateLimiter backed by Redis (same Redis instance used for sessions).
spring:
cloud:
gateway:
routes:
- id: api-route
uri: lb://backend
predicates:
- Path=/api/**
filters:
- SaveSession
- TokenRelay=
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
redis-rate-limiter.requestedTokens: 1
key-resolver: "#{@principalNameKeyResolver}"replenishRate: 10— 10 requests per second per user (steady state)burstCapacity: 20— up to 20 requests allowed in a burst (token bucket algorithm)requestedTokens: 1— each request costs 1 tokenkey-resolver— rate limit key is the authenticated principal name; unauthenticated requests are grouped under"anonymous"
KeyResolver Bean
@Bean
KeyResolver principalNameKeyResolver() {
return exchange -> exchange.getPrincipal()
.map(Principal::getName)
.defaultIfEmpty("anonymous");
}Rate Limit Response
When rate limit is exceeded, Spring Cloud Gateway returns HTTP 429 Too Many Requests with:
X-RateLimit-Remaining: 0
X-RateLimit-Replenish-Rate: 10
X-RateLimit-Burst-Capacity: 20
Angular should handle 429 responses and implement exponential backoff before retrying.
SEC-07: Key Security Mistakes to Avoid
| Mistake | Why It Is Wrong | Correct Approach |
|---|---|---|
http.csrf().disable() | Disabling CSRF exposes the BFF to cross-site request forgery attacks from any origin that can trick a user's browser into sending requests | Use CookieServerCsrfTokenRepository.withHttpOnlyFalse() + SpaServerCsrfTokenRequestHandler; Angular reads XSRF-TOKEN cookie automatically |
Storing JWT in localStorage | XSS code can call localStorage.getItem() and exfiltrate long-lived tokens to an attacker's server | Use BFF-for-SPA with httpOnly session cookie; tokens live in Redis, never in browser |
| No token refresh logic | When access_token expires, all downstream calls fail with 401; users are unexpectedly logged out | BFF handles refresh transparently via ReactiveOAuth2AuthorizedClientManager; include offline_access scope to receive refresh_token |
config.setAllowedOrigins(List.of("*")) with allowCredentials(true) | Browsers reject this combination per CORS spec; all cross-origin cookie-based requests fail | List specific origins: dev server + production domain |
Forwarding refresh_token to downstream services | Downstream services receiving a refresh token can use it independently to obtain new access tokens, bypassing the BFF's session management | TokenRelay= forwards only the access_token; the refresh_token stays in the BFF session |
Omitting SaveSession before TokenRelay= | Race condition: new session not yet flushed to Redis when proxy forwards the request; token relay fails intermittently | Always add SaveSession filter before TokenRelay= in route configuration |
SameSite=Strict on session cookie | Breaks OAuth2 redirect: the IdP redirect back to the BFF is a cross-site navigation; browser does not send SameSite=Strict cookies on redirects from other origins; PKCE verifier retrieval fails | Use SameSite=Lax — blocks cross-site POST while allowing top-level cross-site GET redirects |
| Using implicit grant flow | Deprecated in OAuth 2.1; tokens returned in URL fragments (browser history, Referer header); no refresh token possible | Use Authorization Code Flow + PKCE via the BFF |
Synthesis
This phase establishes the full security implementation for the BFF serving an Angular SPA. The security model has four layers:
- Authentication boundary — BFF is the sole OAuth2 client; the SPA delegates entirely (see BFF-For-SPA)
- Token custody — tokens stored in Redis-backed session, accessed only by BFF processes; browser holds only an opaque
httpOnlycookie - Token propagation — Token-Relay-Pattern via
TokenRelay=orServerOAuth2AuthorizedClientExchangeFilterFunctionforwards access tokens to downstream services without exposing them to the browser - Defense in depth — CSRF filter,
SameSitecookie, CORS origin whitelist, and rate limiting all operate independently, so the failure of one layer does not fully compromise the system
The PKCE mechanism ties the authorization code to the BFF's session, preventing code interception attacks even when the authorization server is compromised in certain ways.
Key forward references:
- Distributed-Tracing — Phase 5 will cover propagating trace context alongside Bearer tokens in downstream calls
- BFF-Deployment-Patterns — Phase 6 will cover Redis HA, BFF multi-region, and session replication strategies
Related Notes
| Note | Relationship |
|---|---|
| BFF-For-SPA | End-to-end SPA security flow — the user-facing view of all SEC sections |
| OAuth2-BFF-Pattern | SEC-01 in depth — BFF as confidential OAuth2 client |
| PKCE | SEC-01 PKCE mechanics — mandatory for Authorization Code Flow |
| Token-Relay-Pattern | SEC-03 in depth — token relay to downstream services |
| Spring-Cloud-Gateway | SCG infrastructure: routes, filters, TokenRelay=, rate limiting |
| BFF-Pattern | General BFF pattern context |
Sources
- Spring Security Reference — Reactive OAuth2 Client: docs.spring.io/spring-security/reference/reactive/oauth2/client/index.html
- Spring Security Reference — Reactive OAuth2 Login: docs.spring.io/spring-security/reference/reactive/oauth2/login/index.html
- Spring Session Reference — Spring Session with Redis: docs.spring.io/spring-session/reference/guides/boot-redis.html
- Spring Cloud Gateway Reference — TokenRelay filter: docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway/gatewayfilter-factories/tokenrelay-factory.html
- Spring Cloud Gateway Reference — RequestRateLimiter filter: docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway/gatewayfilter-factories/requestratelimiter-factory.html
- RFC 9700 — OAuth 2.0 Security Best Current Practice: tools.ietf.org/html/rfc9700
- RFC 7636 — Proof Key for Code Exchange: tools.ietf.org/html/rfc7636
- Philippe De Ryck — "Securing SPAs with the BFF Pattern": pragmaticwebsecurity.com/articles/oauthoidc/bff.html
- OWASP ASVS v4.0 — V3 Session Management + V8 Data Protection requirements