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:

  1. The secret is stored outside the browser (the attacker's execution environment)
  2. 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_secret storage (environment variable, secrets manager)
  • refresh_token issuance (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 from application.yml
  • ServerOAuth2AuthorizedClientRepository — stores authorized clients (tokens) per user
  • ReactiveOAuth2AuthorizedClientManager — manages token acquisition and refresh
  • ServerOAuth2LoginAuthenticationToken processing
  • 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-configuration

Key points:

  • offline_access scope is required to receive a refresh_token from Keycloak
  • {baseUrl} and {registrationId} are Spring Security template variables — they resolve at runtime
  • issuer-uri triggers OIDC discovery; Spring Boot fetches the JWKS URI, token endpoint, and authorization endpoint automatically
  • The ReactiveClientRegistrationRepository bean is auto-configured from this YAML — no @Bean declaration 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 with bff:session: — important when the Redis instance is shared with other applications
  • timeout: 3600s — absolute session timeout; sliding window can be configured separately
  • ssl.enabled — must be true in production; Redis connections without TLS expose tokens in transit

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:

KeyValuePurpose
SPRING_SECURITY_CONTEXTSecurityContext with OAuth2AuthenticationTokenCurrent user identity
org.springframework.security.oauth2.client:authorized-client:keycloak:<username>OAuth2AuthorizedClient (access_token, refresh_token, expires_at)Tokens for relay
SPRING_SECURITY_SAVED_REQUESTOriginal request URL before login redirectPost-login redirect destination
OAuth2 PKCE statecode_verifier, state parameterPKCE 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.

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=2

SaveSession 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:

  1. SameSite=Lax on the session cookie (blocks cross-site POST)
  2. Spring Security CSRF filter with CookieServerCsrfTokenRepository
  3. Angular reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN header

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 token
  • key-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

MistakeWhy It Is WrongCorrect 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 requestsUse CookieServerCsrfTokenRepository.withHttpOnlyFalse() + SpaServerCsrfTokenRequestHandler; Angular reads XSRF-TOKEN cookie automatically
Storing JWT in localStorageXSS code can call localStorage.getItem() and exfiltrate long-lived tokens to an attacker's serverUse BFF-for-SPA with httpOnly session cookie; tokens live in Redis, never in browser
No token refresh logicWhen access_token expires, all downstream calls fail with 401; users are unexpectedly logged outBFF 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 failList specific origins: dev server + production domain
Forwarding refresh_token to downstream servicesDownstream services receiving a refresh token can use it independently to obtain new access tokens, bypassing the BFF's session managementTokenRelay= 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 intermittentlyAlways add SaveSession filter before TokenRelay= in route configuration
SameSite=Strict on session cookieBreaks 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 failsUse SameSite=Lax — blocks cross-site POST while allowing top-level cross-site GET redirects
Using implicit grant flowDeprecated in OAuth 2.1; tokens returned in URL fragments (browser history, Referer header); no refresh token possibleUse 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:

  1. Authentication boundary — BFF is the sole OAuth2 client; the SPA delegates entirely (see BFF-For-SPA)
  2. Token custody — tokens stored in Redis-backed session, accessed only by BFF processes; browser holds only an opaque httpOnly cookie
  3. Token propagationToken-Relay-Pattern via TokenRelay= or ServerOAuth2AuthorizedClientExchangeFilterFunction forwards access tokens to downstream services without exposing them to the browser
  4. Defense in depth — CSRF filter, SameSite cookie, 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

NoteRelationship
BFF-For-SPAEnd-to-end SPA security flow — the user-facing view of all SEC sections
OAuth2-BFF-PatternSEC-01 in depth — BFF as confidential OAuth2 client
PKCESEC-01 PKCE mechanics — mandatory for Authorization Code Flow
Token-Relay-PatternSEC-03 in depth — token relay to downstream services
Spring-Cloud-GatewaySCG infrastructure: routes, filters, TokenRelay=, rate limiting
BFF-PatternGeneral 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