How Spring Security integrates with OAuth2 (client, resource server, authorization server), CORS, SecurityContext and SecurityContextHolder, the authorization-code grant with PKCE, CSRF and when to disable it, method-level security, securing at the gateway (and still in services), SpEL rules, ADMIN/USER endpoint rules, and why digest auth is obsolete.
Published September 25, 2026
Spring Security questions reward correct mental models: the filter chain, the SecurityContext, and the three OAuth2 roles. Use Spring Security 6 APIs in every code sample, and be precise about which OAuth2 role your application plays.
Short answer: An application can play one or more of three OAuth2/OIDC roles, each with its own starter:
spring-boot-starter-oauth2-client): oauth2Login() sends users to an identity provider (Google, Keycloak, Okta) using the authorization-code flow, and obtains tokens. The app also uses OAuth2AuthorizedClientManager to call other APIs with those tokens.spring-boot-starter-oauth2-resource-server): oauth2ResourceServer().jwt() validates incoming bearer tokens (signature through JWKS, expiry, issuer, audience), and maps the token's scopes or roles to authorities.@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())) // an API validating JWTs
.build();
}
Key points to cover:
Learn it in depth → Spring OAuth2 Basics
Short answer: Cross-Origin Resource Sharing is a browser mechanism. By default, browsers block JavaScript from reading responses from a different origin (scheme, host and port). A server opts in by returning Access-Control-Allow-* headers, and for non-simple requests the browser first sends a preflight OPTIONS request.
In Spring, define a CorsConfigurationSource (allowed origins, methods, headers, credentials, max age), and enable http.cors() in the security chain, so preflight requests aren't rejected by authentication. @CrossOrigin works for small cases.
Key points to cover:
SecurityContext and SecurityContextHolder?Short answer: The SecurityContext holds the current Authentication: the principal, credentials (usually erased after login), and the granted authorities. The SecurityContextHolder stores the context for the current thread, using a ThreadLocal by default, so any code can read who is calling.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String user = auth.getName();
@GetMapping("/me") // preferred in controllers
UserView me(@AuthenticationPrincipal Jwt jwt) { return users.view(jwt.getSubject()); }
Key points to cover:
SecurityContextRepository persists the context in the HTTP session for stateful apps. Stateless apps rebuild it from the token on every request.@Async or executor threads automatically. Use DelegatingSecurityContextExecutor or a TaskDecorator.Short answer: The standard flow for users logging in through a browser:
Key points to cover:
state parameter to prevent CSRF on the redirect, and an exact redirect-URI allow-list.Short answer: Spring issues a CSRF token (stored in the session, or in a cookie through CookieCsrfTokenRepository), and requires it on state-changing requests (POST, PUT, PATCH, DELETE), as a form field or header. A malicious site can make the browser send your cookies, but it can't read or guess the token, so forged requests fail.
When to disable it: only for stateless APIs that don't use cookies for authentication, where the client sends an Authorization: Bearer … header explicitly. Browsers never attach that header automatically, so CSRF doesn't apply.
Common trap: "disable CSRF for mobile clients or REST APIs". What matters isn't the client type, it's whether cookies authenticate the request. A SPA using session cookies (a BFF) still needs CSRF protection. Spring Security 6 provides SPA-friendly token handling for that case.
Short answer: Enable it with @EnableMethodSecurity, then annotate service methods:
@PreAuthorize and @PostAuthorize, with SpEL;@PreFilter and @PostFilter;@Secured and JSR-250's @RolesAllowed, when enabled.@Service
class InvoiceService {
@PreAuthorize("hasRole('ACCOUNTANT')")
public void approve(long invoiceId) { … }
@PostAuthorize("returnObject.ownerId == authentication.name or hasRole('ADMIN')")
public Invoice get(long invoiceId) { … }
}
Advantages:
Key points to cover:
Learn it in depth → Role-Based Access Control
Short answer: Configure Spring Cloud Gateway as an OAuth2 resource server that validates JWTs, applies coarse-grained rules per route (the scopes or roles needed for /admin/**), rejects bad tokens early, rate-limits by client, and relays the token downstream (or exchanges it for a narrower, service-specific token).
@Bean
SecurityWebFilterChain gateway(ServerHttpSecurity http) {
return http.authorizeExchange(a -> a
.pathMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.anyExchange().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
Common trap: "then the services don't need to check security". They still do. Services should validate the token themselves, and enforce fine-grained authorisation (zero trust). Otherwise, anything that reaches a service directly (a misconfigured route, internal traffic, a compromised pod) bypasses all security.
Short answer: SpEL expressions in @PreAuthorize/@PostAuthorize can combine authorities, method arguments (#id), return values (returnObject), the authentication object, and custom bean methods (@beanName.method(...)):
@PreAuthorize("hasAuthority('orders:write') and @orderAccess.isOwner(#orderId, authentication)")
public void cancel(long orderId) { … }
@Component("orderAccess")
class OrderAccess {
boolean isOwner(long orderId, Authentication auth) { return orders.ownerOf(orderId).equals(auth.getName()); }
}
Key points to cover:
PermissionEvaluator supports hasPermission(#doc, 'WRITE').Short answer: Put URL rules in the SecurityFilterChain, ordered from most specific to most general, and deny by default:
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
Key points to cover:
permitAll() placed early can accidentally open admin paths.ADMIN > USER), so admins automatically get user permissions.Short answer: An old HTTP authentication scheme (RFC 7616). Instead of sending the password, the client sends a hash of the username, password, a server nonce and the request details. The server checks the hash, and the nonce prevents simple replay.
Key points to cover:
DigestAuthenticationFilter, but the modern answer is HTTPS plus a form login, HTTP Basic, or OAuth2/OIDC tokens, with passwords stored using BCrypt or Argon2.Q: What's the difference between an ID token and an access token? A: The ID token (OIDC, always a JWT) tells the client application who the user is. The access token is presented to APIs to authorise calls. APIs should accept access tokens, not ID tokens.
Q: What's the difference between hasRole and hasAuthority?
A: hasRole('ADMIN') checks for the authority ROLE_ADMIN, because the prefix is added for you. hasAuthority checks the exact string, which is typical for JWT scopes such as SCOPE_orders.read.
Q: How do you map custom JWT claims to roles?
A: Configure a JwtAuthenticationConverter with a JwtGrantedAuthoritiesConverter (set the claim name and prefix), or write a converter that reads, for example, Keycloak's realm_access.roles.
Q: Where should authorisation logic live: the gateway, the service, or the database? A: Coarse checks at the gateway (is this caller valid, and allowed on this API at all). Business authorisation in the service (ownership, limits, workflow state). Data-level safety nets (tenant filters, row-level security) where the risk justifies them.