In this blog post, we are going to look at a complete BFF (Backend-for-Frontend) implementation with Spring Security that uses the OAuth 2.0 authorization code flow with PKCE to log users in, maintain a server-side session, and serve protected data. In this demo, the BFF and the resource server are the same Spring Boot app. The OAuth provider is Authelia running in a Docker container. Because Authelia intentionally supports connections only over HTTPS, even for localhost, the sample also includes an Nginx proxy that terminates TLS for both the app and Authelia in local development.
Using the authorization code flow in a browser-based app is a common pattern, but it has the drawback that the browser receives and stores the OAuth tokens. An attacker who can run JavaScript in the page can read those tokens and send them to an attack server. The BFF pattern is a way to avoid that problem. With BFF the tokens are stored server-side, and the browser only has a session cookie. The backend runs the OAuth client flow, maintains the session, and serves protected data to the browser without exposing any tokens.
End-to-end flow ¶
The diagram below follows the implementation in this sample. The browser starts by sending a request to the combined BFF backend and resource server at /api/me. This public endpoint returns the current authentication state and tells the frontend whether the user is logged in.
If the user is not logged in yet, the response includes a loginUrl that points to the Spring Security authorization request endpoint. This is the first step in the BFF login flow. The browser visits that URL, Spring Security builds the authorization request with PKCE parameters, and the user is redirected to the provider's authorization endpoint. The user authenticates at the provider, and the provider redirects back to the user's browser with the authorization code. This redirect request hits the Spring Security authorization response endpoint, which verifies the state, exchanges the code for tokens, validates the ID token, and stores the authorized client with its tokens in the server-side session. The user is now logged in, and the browser only has a session cookie.
When the browser later visits a protected endpoint such as /api/protected, Spring Security checks if there is an authorized client in the session and if the current access token is still active. If so, the handler executes and serves protected data. If not, the backend can use the server-held refresh token to get a new access token without bothering the user. If that fails or if there is no refresh token, the backend returns a 401 response with a token_inactive error message.
When the user logs out, the frontend calls /api/logout. The backend revokes the refresh token and access token if possible, clears the session, and returns a JSON payload that tells the frontend where to navigate next. If the provider supports an end-session endpoint, that URL is included in the payload so the frontend can clear the provider session cookie as well.
The important part of this flow is that the browser never sees any tokens. The backend runs the entire OAuth protocol, maintains the session, and serves protected data without exposing any credentials to the browser.
Implementation ¶
OAuth2 dance ¶
Spring Security already knows how to run the authorization code flow, maintain the authenticated user session, and store the authorized client server-side. This is done with the oauth2Login config in the SecurityFilterChain.
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
.defaultSuccessUrl("/", true))
This part handles the authorization request, the provider redirect with the authorization code, the token exchange, and the ID token validation. By default Spring Security does not use PKCE. Strictly speaking, PKCE is not required for a confidential client that can keep a client secret, but according to Best Current Practice for OAuth 2.0 Security (RFC 9700, Section 2.1.1) it is recommended even for confidential clients. That is why the sample plugs in a custom OAuth2AuthorizationRequestResolver that adds PKCE parameters to the authorization request.
@Bean
OAuth2AuthorizationRequestResolver authorizationRequestResolver(
ClientRegistrationRepository clientRegistrationRepository) {
DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);
resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
return resolver;
}
Authorize requests ¶
Another config block in the SecurityFilterChain is the authorization config. This is a normal Spring Security authorization configuration that leaves static assets unprotected but requires authentication for the protected API.
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/", "/index.html", "/signed-out.html", "/app.css", "/app.js", "/error", "/api/me",
"/api/logout")
.permitAll()
.requestMatchers("/api/protected", "/api/protected/**")
.authenticated()
.anyRequest()
.authenticated())
Session cookie ¶
For a BFF, the session cookie is the browser-side handle to the authenticated backend session, so it is worth hardening that cookie a bit further than the default JSESSIONID name. This sample configures the session cookie as __Host-bff-session.
The __Host- prefix is a browser-enforced convention. If a cookie name starts with __Host-, the browser will only accept it when all of the following are true:
- the cookie has the
Secureattribute - the cookie has
Path=/ - the cookie does not have a
Domainattribute
That makes a __Host- cookie stronger than a normal JSESSIONID cookie. A normal cookie can still be configured safely, but the browser does not require those safer attributes from the name alone. With the prefix, the browser rejects misconfigured variants instead of silently accepting them. It is a tool to protect from server misconfigurations that could otherwise lead to security issues.
Because this sample already runs behind https://, due to Authelia, and the backend is mounted at the site root, using a __Host- cookie is easy and does not require any additional setup. It's important that all the requirements are also met in a production deployment; otherwise, the browser would reject the cookie and the session would not work at all. But you can always fall back to a normal cookie if you need to; just make sure to set the Secure, HttpOnly, and SameSite attributes to keep it safe.
The session cookie is configured in application.yml with the server.servlet.session.cookie properties. The important part is the name and the attributes that make it a valid __Host- cookie.
server:
port: ${SERVER_PORT:8081}
forward-headers-strategy: framework
servlet:
session:
cookie:
name: __Host-bff-session
path: /
http-only: true
same-site: lax
secure: true
Check out the MDN documentation for more details about cookie name prefixes and their requirements.
Other security configs ¶
In the SecurityFilterChain config, we also find other security-related configurations that are not directly related to the OAuth flow but are important parts of an overall secure implementation.
CSP
CSP (Content Security Policy) is an important defense-in-depth mechanism that helps prevent cross-site scripting (XSS) attacks. It allows you to specify which sources of content are allowed to be loaded by the browser. CSP is controlled by HTTP response headers, and in a Spring Security app, you can configure those headers in the SecurityFilterChain with the headers configurer.
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'")))
In some deployments, you have proxies and other infrastructure in front of the app that inject the CSP headers; in those cases, you can omit this config.
CSRF
CSRF (Cross-Site Request Forgery) is another important attack vector that you need to protect against. In this sample application, the CSRF protection is implemented with a custom filter that checks the Sec-Fetch-Site header, which is a modern browser feature that allows the server to determine the context of the request. Therefore, the application disables the default Spring Security CSRF protection, which is token-based.
.csrf(CsrfConfigurer::disable)
And then adds the custom filter that checks the Sec-Fetch-Site header and rejects unsafe cross-site requests.
.addFilterBefore(new FetchMetadataProtectionFilter(), BasicAuthenticationFilter.class);
private static final class FetchMetadataProtectionFilter extends OncePerRequestFilter {
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE");
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return SAFE_METHODS.contains(request.getMethod());
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String fetchSite = request.getHeader("Sec-Fetch-Site");
if (fetchSite == null || (!"same-origin".equals(fetchSite) && !"same-site".equals(fetchSite))) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Cross-site request rejected");
return;
}
filterChain.doFilter(request, response);
}
}
Note that this approach is simpler to implement and does not require any additional client-side code, but it only works if users use modern browsers that support those features. You can check Can I use to see the current support status for Sec-Fetch-Site and related headers.
Token state check ¶
After creating the session, oauth2Login does not do anything with the tokens. It just leaves them in the session and does not check their state on subsequent requests. This is a perfectly valid implementation, and depending on your security requirements, it might be good enough. If you use this approach, it makes sense to align the session lifetime with the access token lifetime so that when the token expires, the session also expires and the user has to log in again. That way, you get a good security posture without adding any complexity to the implementation.
But if you have stricter security requirements and want to be able to revoke tokens either immediately or within a short time frame, you need to check the token state on every request and not just rely on the session lifetime. If you don't check the token state, a user whose token is revoked could still have an active session until the session expires, which might be a security risk depending on your application.
The implementation in this sample checks the token state on every request and returns a 401 response with a token_inactive error message if the token is not active anymore. To do that the application first creates an OAuth 2.0 client with oauth2Client in the SecurityFilterChain config.
.oauth2Client(Customizer.withDefaults())
The demo application also uses refresh tokens, so it also instantiates a DefaultOAuth2AuthorizedClientManager bean that is configured to handle the refresh flow. The important part is the call to refreshToken() in the builder, that allows the manager to use the refresh token to get a new access token when the current access token is expired or about to expire. The manager then updates the session with the new token, so the user can continue using the app without interruption.
@Bean
DefaultOAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
Next, we need a way to ensure that protected methods check the tokens before executing. One way to implement that is by using a custom method security expression.
To enable method security, the security config includes an EnableMethodSecurity annotation.
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
The application then uses the following meta annotation. This is just for convenience, you could also put @PreAuthorize directly on each of the handler methods. This approach might be easier to maintain if you have a lot of protected handlers that all require the same active-token check.
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("@activeTokenAuthorization.hasActiveToken(authentication)")
public @interface RequireActiveToken {
}
Every method that is annotated with @RequireActiveToken will then call activeTokenAuthorization.hasActiveToken before executing. If the method returns false, the handler will not execute and the request will be denied with a 403 response by default.
The activeTokenAuthorization bean is a service that uses the DefaultOAuth2AuthorizedClientManager to load the authorized client from the session and then checks if the current access token is still active by asking another bean TokenValidationService to check the validity of the token.
@Component("activeTokenAuthorization")
public class ActiveTokenAuthorization {
static final String TOKEN_INACTIVE_ATTRIBUTE = ActiveTokenAuthorization.class.getName() + ".TOKEN_INACTIVE";
private final DefaultOAuth2AuthorizedClientManager authorizedClientManager;
private final TokenValidationService tokenValidationService;
public ActiveTokenAuthorization(DefaultOAuth2AuthorizedClientManager authorizedClientManager,
TokenValidationService tokenValidationService) {
this.authorizedClientManager = authorizedClientManager;
this.tokenValidationService = tokenValidationService;
}
public boolean hasActiveToken(Authentication authentication) {
if (!(authentication instanceof OAuth2AuthenticationToken oauth2AuthenticationToken)) {
return false;
}
ServletRequestAttributes requestAttributes = currentRequestAttributes();
if (requestAttributes == null) {
return false;
}
OAuth2AuthorizedClient authorizedClient;
try {
authorizedClient = this.authorizedClientManager.authorize(OAuth2AuthorizeRequest
.withClientRegistrationId(oauth2AuthenticationToken.getAuthorizedClientRegistrationId())
.principal(oauth2AuthenticationToken)
.attribute(HttpServletRequest.class.getName(), requestAttributes.getRequest())
.attribute(HttpServletResponse.class.getName(), requestAttributes.getResponse())
.build());
}
catch (OAuth2AuthorizationException ex) {
requestAttributes.getRequest().setAttribute(TOKEN_INACTIVE_ATTRIBUTE, Boolean.TRUE);
return false;
}
boolean active = this.tokenValidationService.isActive(authorizedClient);
if (!active) {
requestAttributes.getRequest().setAttribute(TOKEN_INACTIVE_ATTRIBUTE, Boolean.TRUE);
}
return active;
}
private ServletRequestAttributes currentRequestAttributes() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes servletRequestAttributes) {
return servletRequestAttributes;
}
return null;
}
}
The token validation service runs three checks. First, it checks if there is an authorized client in the session. Then it checks if there is an access token and whether it is locally expired, with a five-second buffer. Finally, it asks the provider if the token is still active by sending a request to the introspection endpoint. Note that this calls the provider for each protected request, which adds latency to each request. The advantage is that this allows immediate token revocation.
Because the call to the provider adds latency to every request, a more practical implementation is to use short-lived access tokens (e.g. 5 minutes) and only check the token state when the token is expired or about to expire. That way, you get a good balance between security and performance, with the ability to revoke tokens within a few minutes without adding latency to every request. To switch to this pattern, you only need to remove the opaqueTokenIntrospector.introspect call and configure the token lifetime in your provider.
@Service
public class TokenValidationService {
private final OpaqueTokenIntrospector opaqueTokenIntrospector;
public TokenValidationService(OpaqueTokenIntrospector opaqueTokenIntrospector) {
this.opaqueTokenIntrospector = opaqueTokenIntrospector;
}
public boolean isActive(OAuth2AuthorizedClient authorizedClient) {
if (authorizedClient == null) {
return false;
}
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
if (accessToken == null) {
return false;
}
Instant expiresAt = accessToken.getExpiresAt();
if (expiresAt != null && expiresAt.isBefore(Instant.now().plusSeconds(5))) {
return false;
}
try {
this.opaqueTokenIntrospector.introspect(accessToken.getTokenValue());
return true;
}
catch (OAuth2AuthenticationException ex) {
return false;
}
}
}
The OpaqueTokenIntrospector bean calls the provider introspection endpoint.
@Bean
OpaqueTokenIntrospector opaqueTokenIntrospector(ClientRegistrationRepository clientRegistrationRepository,
@Value("${app.security.registration-id}") String registrationId) {
ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(registrationId);
if (clientRegistration == null) {
throw new IllegalStateException("Missing OAuth2 client registration: " + registrationId);
}
Object introspectionEndpoint = clientRegistration.getProviderDetails()
.getConfigurationMetadata()
.get("introspection_endpoint");
if (!(introspectionEndpoint instanceof String endpoint) || endpoint.isBlank()) {
throw new IllegalStateException("Authelia discovery metadata does not expose an introspection endpoint");
}
return SpringOpaqueTokenIntrospector.withIntrospectionUri(endpoint)
.clientId(clientRegistration.getClientId())
.clientSecret(clientRegistration.getClientSecret())
.build();
}
Token expiration handling ¶
When Spring Security denies access using the PreAuthorize annotation, it throws an AccessDeniedException, which is handled by the default access-denied handler that returns a 403 response. 403 Forbidden is technically correct, but it does not give the frontend any information about why the access was denied.
If you instead want to send a 401 response with a custom error message, you can implement a custom access-denied handler.
In this sample, the ActiveTokenAuthorization service sets a request attribute when the token is inactive (TOKEN_INACTIVE_ATTRIBUTE). In the Spring Security filter chain, a custom access-denied handler checks for that attribute and returns a 401 response with a token_inactive error message instead of a 403 response.
.exceptionHandling(
exceptions -> exceptions.accessDeniedHandler((request, response, accessDeniedException) -> {
if (Boolean.TRUE
.equals(request.getAttribute(ActiveTokenAuthorization.TOKEN_INACTIVE_ATTRIBUTE))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter()
.write("{\"error\":\"token_inactive\",\"message\":\"The backend session exists, but the access token is no longer active.\"}");
return;
}
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
}))
This is not something that is required for the token state check itself. You could also just return a 403 response and let the frontend handle that as an indication that the token is not active anymore.
Logout ¶
Depending on your requirements, you can implement either a simple local logout that just clears the session on this backend and leaves the provider session intact, or a more complex logout that clears the session on the backend, revokes the tokens at the provider, and also clears the provider session if possible.
Because this demo uses a more complex logout workflow it does not configure logout in the SecurityFilterChain. Instead it uses a normal Spring MVC controller method as the logout endpoint, and a custom service OidcLogoutService that handles the logout logic.
@PostMapping("/logout")
public Map<String, Object> logout(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) {
return this.oidcLogoutService.logout(authentication, request, response);
}
The logout service first checks if the user is logged in. If so, it revokes the refresh token and access token if possible, and then builds the provider logout URL if the provider supports it. Finally, it clears the session, expires the session cookie, and returns a JSON payload that includes the URL to navigate to next, which is either the provider end-session endpoint or a local signed-out page.
public Map<String, Object> logout(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) {
String redirectUrl = buildSignedOutUrl(request);
boolean providerLogout = false;
if (authentication instanceof OAuth2AuthenticationToken oauth2AuthenticationToken) {
String registrationId = oauth2AuthenticationToken.getAuthorizedClientRegistrationId();
ClientRegistration clientRegistration = this.clientRegistrationRepository
.findByRegistrationId(registrationId);
OAuth2AuthorizedClient authorizedClient = this.authorizedClientRepository
.loadAuthorizedClient(registrationId, oauth2AuthenticationToken, request);
revokeTokensIfSupported(clientRegistration, authorizedClient);
this.authorizedClientRepository.removeAuthorizedClient(registrationId, oauth2AuthenticationToken, request,
response);
String providerLogoutUrl = buildProviderLogoutUrl(request, clientRegistration, oauth2AuthenticationToken);
if (providerLogoutUrl != null) {
redirectUrl = providerLogoutUrl;
providerLogout = true;
}
}
this.logoutHandler.logout(request, response, authentication);
expireSessionCookie(request, response);
return Map.of("redirectUrl", redirectUrl, "providerLogout", providerLogout);
}
Wrapping up ¶
This blog post showed a complete BFF implementation with Spring Security that uses the OAuth 2.0 authorization code flow with PKCE to log users in, maintain a server-side session, and serve protected data. Spring Security has built-in support for the OAuth 2.0 authorization code flow, and makes it easy to implement a secure BFF that does not expose any tokens to the browser. Depending on your security requirements, you can choose to check the token state on every request or just rely on the session lifetime. You can also implement a simple local logout or a more complex logout that also revokes tokens and clears the provider session.