Spring Security Guide
Reference · Spring Security 6.x / 7.x

Spring Security, end to end

Every part of Spring Security in one page: the filter chain, authentication, the SecurityContext, sessions, authorization, exploit protection, JWT and OAuth2, microservices, testing and production. Each section points back to the code in this project where it applies.

For every concept's options, failure modes and a hypothesis to test, open the Decision Cards.

1. Big picture

Spring Security is a chain of filters that runs before your controller. It does three jobs: confirms who the caller is (authentication), checks what they may do (authorization), and protects against common web attacks (exploit protection).

Mental model in one line: A request arrives → filters read the credentials (password, session cookie or token) and build an Authentication object → it is stored in the SecurityContext → the last filter checks the rules → the request reaches the controller, or gets 401 or 403.

Three questions for every request:

Question Spring piece On failure
Who are you? Authentication filters + AuthenticationManager 401 Unauthorized
Are you allowed? AuthorizationFilter / method security 403 Forbidden
Is this request safe? CSRF filter, headers, CORS, session fixation 403, or the browser blocks it

Two basic truths behind everything:

  1. HTTP is stateless. The server has no memory, so every request must carry proof (a session cookie or a token).
  2. Authentication and authorization are separate questions. First find out who the caller is, then check permissions.

Version note: This guide covers Spring Security 6.x (Boot 3.x) and 7.x (Boot 4.x). This project runs Spring Boot 3.5.5 with Spring Security 6.5 and Spring Authorization Server 1.5.

Mind map

Filters decide who you are and what you may do before the controller runs Learn in order, 1 to 8. Orange: who are you? (401) and are you allowed? (403). Click a box to jump. Spring Security filters before controller 1ArchitectureFilters run before the controllerFilterChainProxy picks 1 chainAuthorizationFilter runs last2Authentication (401)Filter to AuthenticationManagerProvider + UserDetailsServicePasswordEncoder: BCrypt, Argon23SecurityContextThreadLocal, cleared per request@Async needs a Delegating executorSave it yourself (Security 6)4SessionsJSESSIONID, new id on loginSTATELESS for token APIsScale: Spring Session + Redis5Authorization (403)URL rules: first match wins@PreAuthorize in service layerOwnership checks stop IDOR6Exploit protectionCSRF: cookie apps need tokensCORS is a browser rule onlyHeaders on; HTTPS behind proxies7JWT and OAuth2Verify signature, exp, iss, audUsers: auth code + PKCEServices: client credentials8Testing and productionTest allow + deny for every ruleNo tokens in logs; lock actuatorSync clocks, patch CVEs

Where to see it in this project

TopicFile in this repoWhat it shows
Multiple chains (§3)payment-authorization-server/…/Config/SecurityConfig.java@Order(1) OAuth2 endpoints chain, @Order(2) form login chain
Scope rules, stateless (§7, §9)payment-service-a/…/Security/SecurityConfig.java, payment-service-b/…/Config/SecurityConfig.javaSCOPE_payment.read / write per HTTP method, CSRF off, no sessions
Ownership, 404 not 403 (§7.6)payment-service-b/…/Service/PaymentServiceImpl.javaOwner taken from the JWT sub via SecurityContextHolder
Client credentials (§10, §11)code/settlement-job/…/config/OAuth2ClientConfig.javaToken for a @Scheduled job with no HTTP request
Deny everything (§7.2)code/settlement-job/…/config/SecurityConfig.javaanyRequest().denyAll() for an outbound-only app
Dev-only chain (§8.3)payment-service-b/…/Config/SecurityConfig.javaH2 console chain with frameOptions().sameOrigin(), @Profile("dev")
Signing keys (§9.4)payment-authorization-server/…/Config/SecurityConfig.javaRSA key per start in dev, PKCS12 keystore otherwise

Open gaps worth fixing next: no audience check in any service (§9.3), and Services A and B have no security tests yet (§13).

2. Architecture: how a request flows

Spring Security is built entirely on Servlet Filters. Filters run before the DispatcherServlet, so security happens without your controller code knowing.

Layers, outside to inside:

  1. Tomcat filter chain: the normal filters in the servlet container.
  2. DelegatingFilterProxy: Tomcat does not know about Spring beans. This proxy registers as a Tomcat filter and hands the work to the Spring bean springSecurityFilterChain.
  3. FilterChainProxy: that bean. It holds every SecurityFilterChain in your app and picks the first chain that matches the request. It clears the SecurityContext after the request and rejects malformed URLs (such as .. or encoded slashes) through the HttpFirewall.
  4. SecurityFilterChain: an ordered list of security filters, built from your HttpSecurity config.
  5. DispatcherServlet → Controller: reached only if every filter passes.

Default filters in order, and the job of each (which ones are present depends on your config):

# Filter Job
1 DisableEncodeUrlFilter Stops the session id leaking into URLs
2 WebAsyncManagerIntegrationFilter Passes the context to async request threads (Callable)
3 SecurityContextHolderFilter Loads the context from the session or repository; clears it after the request
4 HeaderWriterFilter Adds security headers (nosniff, frame options, HSTS, cache)
5 CorsFilter CORS preflight and headers (when http.cors() is set)
6 CsrfFilter Checks the CSRF token on state-changing requests
7 LogoutFilter Handles /logout: invalidates the session, clears the context, deletes cookies
8 OAuth2AuthorizationRequestRedirectFilter Starts OAuth2 login: redirects to the provider
9 OAuth2LoginAuthenticationFilter Turns the provider callback (code) into tokens and a login
10 UsernamePasswordAuthenticationFilter Form login POST to /login
11 DefaultLoginPageGeneratingFilter / DefaultLogoutPageGeneratingFilter Default login and logout pages
12 BasicAuthenticationFilter Authorization: Basic header
13 BearerTokenAuthenticationFilter Authorization: Bearer JWT or opaque token
14 RequestCacheAwareFilter Returns the user to the original request after login
15 SecurityContextHolderAwareRequestFilter Makes request.isUserInRole() and getRemoteUser() work
16 RememberMeAuthenticationFilter Logs in from a remember-me cookie
17 AnonymousAuthenticationFilter Sets an anonymous token when nobody is logged in
18 SessionManagementFilter Session fixation and concurrency (depending on config)
19 ExceptionTranslationFilter Turns security exceptions into 401 (entry point) or 403 (denied handler)
20 AuthorizationFilter The last gate: checks URL rules

The pattern to remember: first load the context, then protection (headers, CORS, CSRF), then authentication filters (each logs in if it sees its credentials, otherwise passes the request on), and last exception translation + authorization.

Adding your own filter: http.addFilterBefore(myFilter, AuthorizationFilter.class) (or addFilterAfter / addFilterAt). Extend OncePerRequestFilter. Do not make it a @Component, or Boot also registers it with Tomcat and it runs twice.

See the real list: in a lab, use @EnableWebSecurity(debug = true) or logging.level.org.springframework.security=TRACE (the authorization server in this project already logs at trace).

3. Configuration

You define all your security in one or more SecurityFilterChain beans using the HttpSecurity DSL.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {

  @Bean
  SecurityFilterChain api(HttpSecurity http) throws Exception {
    http
      .securityMatcher("/api/**")                       // which URLs this chain handles
      .authorizeHttpRequests(a -> a
          .requestMatchers("/api/public/**").permitAll()
          .requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
          .anyRequest().authenticated())                 // anyRequest always last
      .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
      .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .csrf(c -> c.disable())                            // only for stateless token APIs
      .cors(Customizer.withDefaults());
    return http.build();
  }

  @Bean
  PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }
}

What Spring Boot gives you with no config: every URL requires authentication; form login + HTTP Basic; a user with a random password printed in the log; CSRF on; security headers on; default login and logout pages. Defining your own SecurityFilterChain bean turns off Boot's default chain; defining a UserDetailsService or AuthenticationProvider bean removes the generated user.

Multiple chains: give each chain a securityMatcher and an @Order. FilterChainProxy runs only the first matching chain. A common setup: a stateless JWT chain for /api/**, a separate chain for /actuator/**, and a form-login chain for everything else (no matcher, last in order). If a chain without a matcher is not last, startup fails.

Rule order: rules inside authorizeHttpRequests are checked top to bottom and the first match wins. Put specific rules first and the general one (anyRequest) last.

Version differences (useful when reading old code):

Old (Security 5.x) New (6.x / 7.x)
extends WebSecurityConfigurerAdapter @Bean SecurityFilterChain (adapter removed in 6)
authorizeRequests() authorizeHttpRequests() (uses AuthorizationManager)
antMatchers(), mvcMatchers() requestMatchers()
@EnableGlobalMethodSecurity(prePostEnabled = true) @EnableMethodSecurity (pre/post on by default)
.and() chaining Lambda DSL x -> x... (and() removed in 7)
javax.servlet jakarta.servlet (Boot 3+)
Context saved to the session automatically You must save it explicitly (SecurityContextRepository) when you set it yourself

For Security 7 (Boot 4), read the official migration guide. Fixing the deprecation warnings on 6.x makes the move to 7 much easier.

4. Authentication

Authentication proves who the caller is. Every login style in Spring (form, basic, JWT, OAuth2) follows the same pattern: a filter reads the credentials → builds an unauthenticated Authentication → passes it to the AuthenticationManager → if an authenticated Authentication comes back, stores it in the context.

4.1 The Authentication object

Field Meaning Example
principal Who UserDetails, Jwt, OidcUser
credentials Proof Password (erased after login)
authorities Permissions ROLE_ADMIN, SCOPE_payment.read
authenticated Verified or not true after success
details Extra IP address, session id

Common types: UsernamePasswordAuthenticationToken, JwtAuthenticationToken, OAuth2AuthenticationToken, AnonymousAuthenticationToken, RememberMeAuthenticationToken.

4.2 The engine

  1. AuthenticationManager: an interface with one method, authenticate().
  2. ProviderManager: the main implementation. It holds a list of AuthenticationProviders, asks each one whether it supports this token type, and hands the token to the first one that does. If none supports it, it throws ProviderNotFoundException. If it has a parent manager, it falls back to that.
  3. AuthenticationProvider: does the actual check. Examples: DaoAuthenticationProvider (username/password), JwtAuthenticationProvider, OidcAuthorizationCodeAuthenticationProvider, or your own provider (LDAP, OTP, API key).
  4. DaoAuthenticationProvider steps: UserDetailsService.loadUserByUsername() → account checks (locked, disabled, expired) → PasswordEncoder.matches() → credentials-expired check → a successful token with authorities.

4.3 UserDetails and UserDetailsService

UserDetails is your user in a shape Spring understands: username, password hash, authorities, isEnabled, isAccountNonLocked, isAccountNonExpired, isCredentialsNonExpired. UserDetailsService takes a username and returns UserDetails (from JPA, JDBC, LDAP or memory). If the user does not exist, throw UsernameNotFoundException; Spring hides it as "Bad credentials" to stop user enumeration.

4.4 Passwords

4.5 Login styles

Style How credentials arrive Use when
Form login POST /login with username + password Server-rendered web apps (the auth server login page here)
HTTP Basic Authorization: Basic base64(user:pass) on every request Internal tools and quick tests; always over HTTPS
Bearer token (JWT/opaque) Authorization: Bearer ... REST APIs, SPAs, mobile, microservices (Services A, B, Notification)
OAuth2/OIDC login Redirect to Google, Keycloak, Okta or your own auth server SSO, social login
Remember-me Long-lived cookie "Keep me logged in"
X.509 / mTLS Client certificate in TLS Service-to-service, high security
One-time token / passkeys Magic link, WebAuthn Passwordless (newer versions; check yours)
Custom Your filter + provider API keys, OTP, legacy SSO headers

Custom JSON login endpoint (for an SPA): call authenticationManager.authenticate(...), set the context, and call securityContextRepository.saveContext(context, req, res). Without that, the user is anonymous on the next request (a Security 6 change).

4.6 After login, and logout

5. SecurityContext: where the logged-in user lives

A SecurityContext holds one Authentication. The SecurityContextHolder holds the context, by default in a ThreadLocal, so only the thread handling the request can see it.

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String user = auth.getName();   // for a JWT this is the "sub" claim

Service B does exactly this in PaymentServiceImpl to find the payment owner. In a controller you can inject it directly: @AuthenticationPrincipal UserDetails user, @AuthenticationPrincipal Jwt jwt, or a Principal principal parameter.

Lifecycle per request:

  1. SecurityContextHolderFilter loads the context from the SecurityContextRepository (for sessions, the SPRING_SECURITY_CONTEXT attribute in the HttpSession).
  2. On a new login, the authentication filters set the context and save it to the repository.
  3. After the request the holder is cleared, so a reused pool thread never leaks a user.

Repositories: HttpSessionSecurityContextRepository (sessions), RequestAttributeSecurityContextRepository (current request only, stateless), DelegatingSecurityContextRepository (both). With stateless JWT, the context is rebuilt from the token on every request and nothing is saved.

Holder strategies:

Strategy Behaviour Note
MODE_THREADLOCAL Same thread only Default, safest
MODE_INHERITABLETHREADLOCAL Copied to child threads Can leak the wrong user in thread pools; avoid
MODE_GLOBAL One for the whole JVM Desktop apps only

Threads and async (where real bugs happen):

The anonymous user trap: when nobody is logged in, getAuthentication() is not null. It is an AnonymousAuthenticationToken with ROLE_ANONYMOUS, and isAuthenticated() returns true. To check whether someone is really logged in, use AuthenticationTrustResolver.isAnonymous(auth) or check the type.

6. Sessions

With session-based login, the server remembers the user in memory (the session) and the browser sends a JSESSIONID cookie. It is simple and easy to revoke, but the server becomes stateful. In this project only the auth server login page uses a session; Services A, B and Notification are STATELESS.

Creation policy (sessionManagement().sessionCreationPolicy(...)):

Policy Meaning
IF_REQUIRED Default. Creates a session at login
ALWAYS A session for every request
NEVER Security never creates one, but uses one if it exists
STATELESS Security neither creates nor uses a session. For JWT APIs. It does not stop your own code from creating one

Session fixation protection: an attacker gives the victim a known session id; if the victim logs in with it, the attacker gets in too. The fix is a new session id after login (default changeSessionId). Other options: migrateSession, newSession, none (never use).

Concurrent sessions: maximumSessions(1) makes a new login expire the old session; maxSessionsPreventsLogin(true) blocks the new login instead. You need an HttpSessionEventPublisher bean, or expired sessions stay in the count. In a cluster, the SessionRegistry must be shared (Spring Session).

Timeout: server.servlet.session.timeout=30m. When it expires, the user goes to invalidSessionUrl or gets 401. An absolute timeout (a maximum time even while active) is not built in; build it yourself.

Cookie settings: server.servlet.session.cookie.http-only=true (JavaScript cannot read it), secure=true (HTTPS only), same-site=lax or strict (reduces CSRF risk).

Scaling to multiple servers: an in-memory session lives on one server. If the load balancer sends the user to another server, they are logged out. Options: sticky sessions (simple, but sessions are lost when a server dies), Spring Session + Redis or JDBC (shared; recommended), or stateless tokens. This is the stateful vs stateless trade-off from the backend communication course.

7. Authorization

Authorization answers "is this caller allowed to do this?" It works at three levels: URL level (filter), method level (proxy) and object level (is this specific record yours?). Most production bugs happen at the third level.

7.1 Authorities vs roles

7.2 URL authorization

.authorizeHttpRequests(a -> a
    .requestMatchers("/actuator/health", "/error").permitAll()
    .requestMatchers(HttpMethod.POST, "/payments/**").hasAuthority("SCOPE_payment.write")
    .requestMatchers(HttpMethod.GET, "/payments/**").hasAuthority("SCOPE_payment.read")
    .requestMatchers("/reports/**").access(myAuthorizationManager)
    .anyRequest().authenticated())

Options: permitAll, denyAll, authenticated, anonymous, fullyAuthenticated (not via remember-me), rememberMe, hasRole, hasAnyRole, hasAuthority, hasAnyAuthority, access(AuthorizationManager). Rules run top to bottom and the first match wins. If you do not permit /error, real errors show up as 401. The settlement job uses anyRequest().denyAll() because nobody should call into it.

permitAll() vs web.ignoring(): with permitAll the chain still runs (headers, context, CSRF); ignoring skips security entirely. Almost always use permitAll.

7.3 Method security

Turn it on with @EnableMethodSecurity.

Annotation When it checks Use
@PreAuthorize("hasRole('ADMIN')") Before the method Most common
@PreAuthorize("#id == authentication.name") Before, using parameters Ownership
@PostAuthorize("returnObject.owner == authentication.name") After the method Read methods only (a write has already happened)
@PreFilter / @PostFilter Filters a collection Small lists only; filter large data in the query
@Secured("ROLE_ADMIN") Before Needs securedEnabled = true
@RolesAllowed Before JSR-250, needs jsr250Enabled = true

SpEL can call a bean: @PreAuthorize("@paymentSecurity.isOwner(#paymentId, authentication)"). This keeps complex rules clean.

Proxy limits: because of the Spring AOP proxy, a call from inside the same class (this.method()) skips the check, and private/final methods are not checked. Define meta-annotations (a custom @IsAdmin) to avoid repeating expressions.

Where to put it: in the service layer, so every entry point (controller, scheduler, Kafka listener) is covered. If it lives only in the controller, another path can bypass it.

7.4 AuthorizationManager

In Security 6, both URL and method checks run on AuthorizationManager<T>: check(authentication, object) returns granted or denied. Write a custom implementation for tenant checks, time-based rules or feature flags, and plug it in with .access(...) or method security.

7.5 Role hierarchy

A RoleHierarchy bean: RoleHierarchyImpl.fromHierarchy("ROLE_ADMIN > ROLE_MANAGER > ROLE_USER"). ADMIN gets every lower role automatically, so you do not repeat hasAnyRole("ADMIN","USER") in rules.

7.6 Object-level security (IDOR)

If /payments/{id} is protected only by a scope, user A can try B's payment id and read B's data. This is IDOR, a top OWASP risk. Service B solves it by looking up the payment together with the token's sub, and returns 404 instead of 403 so an attacker cannot even learn that the id exists. Ways to fix it:

  1. Filter by owner in the query: findByIdAndOwnerId(id, currentUserId).
  2. @PreAuthorize("@paymentSecurity.isOwner(#id, authentication)").
  3. @PostAuthorize on read methods.
  4. Multi-tenant: take the tenant id from the token and apply it in every query (Hibernate filter or row-level security).
  5. The Spring Security ACL module: per-object permission tables (heavy; for rare cases).

7.7 401 vs 403 handling

ExceptionTranslationFilter catches the exceptions:

For REST APIs, customise both to return a JSON ProblemDetail. Exceptions thrown inside filters never reach @ControllerAdvice (this project's GlobalExceptionHandler), so handle them here.

8. Exploit protection

Even with correct login and roles, attacks can happen through browser behaviour. Spring turns some protections on by default; others are your job.

8.1 CSRF (Cross-Site Request Forgery)

Problem: browsers send cookies automatically. While a user is logged in to your bank, an evil site can submit a hidden form to POST /transfer; the browser attaches the session cookie and the server thinks the request is real.

Fix: state-changing requests (POST, PUT, PATCH, DELETE) must carry a CSRF token that the evil site cannot read. CsrfFilter checks it; missing or wrong means 403.

8.2 CORS (Cross-Origin Resource Sharing)

This is not server security. It is a browser rule: when JavaScript on app.com calls api.com, the browser first asks api.com whether it allows that (a preflight OPTIONS request). CORS does not apply to curl, Postman, PowerShell or server-to-server calls.

8.3 Security headers (on by default)

Header Protects against
X-Content-Type-Options: nosniff The browser guessing a file type and running it as a script
X-Frame-Options: DENY Clickjacking (your page inside someone's iframe). Service B relaxes it to sameOrigin only for the dev H2 console
Cache-Control: no-cache, no-store Private pages cached on shared computers
Strict-Transport-Security (HTTPS only) Downgrade to HTTP
X-XSS-Protection: 0 The old browser XSS filter (disabled on purpose; it caused bugs)
Content-Security-Policy (you add it) XSS: controls which scripts may load
Referrer-Policy, Permissions-Policy (you add them) URL leaks, camera and microphone access

8.4 HTTPS and proxies

8.5 What Spring Security does not do (your job)

9. JWT and resource server (stateless)

In a stateless setup the server does not remember the user. Every request brings a signed token; the server verifies the signature and trusts the information inside. In Spring, the side that verifies tokens is the resource server. Services A, B and Notification are all resource servers.

9.1 JWT anatomy

header.payload.signature, each part Base64URL-encoded.

9.2 Resource server setup

spring.security.oauth2.resourceserver.jwt:
  issuer-uri: http://localhost:9000
  audiences: payment-client          # optional, see 9.3
http.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));

What happens on every request: BearerTokenAuthenticationFilter takes the token from the header → JwtDecoder checks the signature (JWKS public key), exp/nbf (with 60 s clock skew), iss, and aud if configured → JwtAuthenticationConverter turns claims into authorities → a JwtAuthenticationToken goes into the context.

On failure: 401 + WWW-Authenticate: Bearer error="invalid_token".

9.3 Claims to authorities, and the audience

9.4 Keys and rotation

9.5 The revocation problem

A JWT stays valid until exp. Logout, a password change or disabling the user does not stop it. This project keeps access tokens to 5 minutes. Options:

Approach Revoke speed Cost
Short access token (5–15 min) + refresh token Within the token lifetime Refresh token store and rotation (stateful)
Deny-list (jti in Redis) Instant A Redis lookup on every request
Opaque token + introspection Instant A network call to the auth server on every request (caching helps)

9.6 Where should a browser keep tokens?

9.7 Do not build your own token server casually

Signing JWTs in your own login endpoint works for small apps, but then you maintain key rotation, refresh, revocation and MFA yourself. Use Keycloak, Okta, Auth0, Azure AD or Spring Authorization Server (as this project does). If you do write a custom JWT filter, place it before AuthorizationFilter and send its exceptions to the entry point; the built-in oauth2ResourceServer is the better option.

10. OAuth2 and OpenID Connect

OAuth2 is authorization (delegated access): "let this app access my data on my behalf." OIDC is OAuth2 plus identity: "tell me who this user is" (it adds the ID token). "Login with Google" is OIDC.

10.1 Four roles

Role Meaning In this project
Resource owner The data owner (the user) nithin or alice
Client The app that wants access payment-client (Postman), payment-service-b, settlement-job
Authorization server Issues tokens payment-authorization-server on :9000
Resource server The protected API; verifies tokens Service A :8080, Service B :8081, Notification :8082

10.2 Tokens

10.3 Grant types (flows)

Grant For Status
Authorization Code + PKCE User login (web, SPA, mobile) Use this
Client Credentials Service-to-service, no user Use this
Refresh Token Renewing access Use with rotation
Device Code TVs, CLIs Special cases
Token Exchange (RFC 8693) Swapping one token for another (delegation) Microservices
Implicit Old SPA flow Deprecated; do not use
Password (ROPC) App collects the user's password Deprecated; do not use

Authorization code flow, step by step: the browser hits the client → the client redirects to the auth server (client_id, redirect_uri, scope, state, PKCE code_challenge) → the user logs in and consents → the auth server redirects back to redirect_uri with a code → the client exchanges the code plus its secret/verifier for tokens over a back channel. state prevents CSRF; PKCE makes a stolen code useless.

10.4 Spring's three OAuth2 modules

  1. oauth2Login() (client, user login): the flow starts at /oauth2/authorization/{registrationId} and the callback is /login/oauth2/code/{id}. The result is an OAuth2User / OidcUser principal saved in the session. Map roles with a GrantedAuthoritiesMapper or a custom OidcUserService.
  2. oauth2Client() (client, calling APIs): OAuth2AuthorizedClientManager gets, caches and refreshes tokens. RestClient/WebClient interceptors add the Bearer header automatically. Client credentials live here (see the settlement job's OAuth2ClientConfig).
  3. oauth2ResourceServer() (API side): see section 9.

Plus Spring Authorization Server, if you want to run your own auth server. In this project its chain has @Order(1) and matches only the OAuth2 endpoints; the form-login chain is @Order(2) for everything else.

10.5 Config example (client credentials)

spring.security.oauth2.client:
  registration.settlement-job:
    provider: payment-auth
    client-id: settlement-job
    client-secret: ${SETTLEMENT_SECRET}
    authorization-grant-type: client_credentials
    scope: payment.read,payment.settle
  provider.payment-auth:
    token-uri: http://localhost:9000/oauth2/token

Common production issues: redirect_uri mismatch (http vs https behind a proxy), state not found (session lost on another node), clock skew, wrong scopes, client secret rotation.

11. Microservices security

Zero trust: do not trust the internal network. Every service verifies every call, so one compromised service does not open up the rest. Service B re-validates every token instead of trusting Service A.

11.1 Typical architecture

  1. Browser/mobile → API gateway (Spring Cloud Gateway, Nginx or a cloud gateway): user login (OIDC), rate limiting, CORS, TLS. With a BFF, tokens live here.
  2. Gateway → services: forwards the user's access token (token relay, as Service A does), or a narrower token the gateway exchanged for.
  3. Service → service: token relay or exchange when the user's context is needed; client credentials for background and system calls (Service B → Notification, settlement job → Service B).
  4. Every service is a resource server: do not skip checks because the gateway already checked. Verify signature, iss, aud and scope in every service.

11.2 Service-to-service options

Option Identity Pros Cons
Shared API key Weak (who called?) Simple Copied everywhere, hard to rotate, no scopes
OAuth2 client credentials The service (client_id) Central control, scopes, easy rotation Depends on the auth server
Token relay The user User-level audit and checks downstream Token audience must fit; long chains carry broad tokens
Token exchange (RFC 8693) User + acting service Narrow, per-service tokens More setup
mTLS The service (certificate) Strong, at network level Certificate management
Service mesh (Istio/Linkerd) The workload Automatic mTLS, no code change Needs Kubernetes and heavy infrastructure

A common production combination: mesh mTLS for transport + JWT for app-level authorization.

11.3 Rules that prevent real incidents

11.4 Spring pieces

12. Reactive (WebFlux) security

Same concepts, different plumbing. In WebFlux one request does not stay on one thread, so ThreadLocal does not work.

Servlet (MVC) Reactive (WebFlux)
@EnableWebSecurity @EnableWebFluxSecurity
SecurityFilterChain + HttpSecurity SecurityWebFilterChain + ServerHttpSecurity
authorizeHttpRequests authorizeExchange
SecurityContextHolder (ThreadLocal) ReactiveSecurityContextHolder (Reactor Context)
UserDetailsService ReactiveUserDetailsService
@EnableMethodSecurity @EnableReactiveMethodSecurity (methods return Mono/Flux)

Getting the user: ReactiveSecurityContextHolder.getContext().map(ctx -> ctx.getAuthentication()). Spring Cloud Gateway is reactive, so gateway security is written in this style.

13. Testing security

A security rule without a test is an assumption. Test both the allowed case and the denied case for every rule.

Tool What it does
spring-security-test The test support dependency
@WithMockUser(roles = "ADMIN") A fake authenticated user; skips authentication, tests authorization
@WithUserDetails("nithin") Loads the user from your real UserDetailsService
@WithSecurityContext Your own custom annotation (for example a tenant user)
.with(user("nithin").roles("USER")) MockMvc request post-processor
.with(csrf()) Adds a CSRF token for POST/PUT/DELETE tests
.with(jwt().authorities(...)) A fake JWT with no signature; fast
.with(oauth2Login()), .with(oidcLogin()) An OAuth2 login user
SecurityMockMvcResultMatchers.authenticated() Asserts the login result
Testcontainers Real services and tokens end to end
@Test
void readScopeCannotCreatePayment() throws Exception {
  mvc.perform(post("/payments").contentType(APPLICATION_JSON).content(body)
         .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_payment.read"))))
     .andExpect(status().isForbidden());
}

@Test
void noTokenGets401() throws Exception {
  mvc.perform(get("/payments/1")).andExpect(status().isUnauthorized());
}

Test slices: in @WebMvcTest, confirm your SecurityConfig is actually loaded (add @Import(SecurityConfig.class) if needed); otherwise the tests check Boot's default security, not your rules. For method security, call the service bean directly in a @SpringBootTest.

Checklist per endpoint: anonymous → 401 · wrong scope → 403 · right scope → 200 · another user's object → 404 here (403 elsewhere) · POST without CSRF (session apps) → 403 · expired or tampered token → 401. This project's scripts/test-all.ps1 runs these checks end to end with real tokens.

14. Production

Logging: in development, logging.level.org.springframework.security=TRACE shows what each filter decided. In production use INFO/WARN. Never log passwords, Authorization headers, tokens or session ids, and check your Nginx/Tomcat access log formats too.

Audit: authentication success and failure events, authorization denied events (AuthorizationDeniedEvent, once you enable the publisher), and admin actions. Record who, what, when and from where (IP).

Monitoring: 401/403 rate, login failure rate, token validation errors and auth server latency. Spring Security 6 Micrometer observations show filter chain and authentication timing in traces (this project already samples every trace and prints the trace id in logs). A sudden 401 spike after a deploy usually means a config bug or a key/clock issue.

Actuator: /actuator/health (and info) can be public; env, heapdump, configprops and loggers must never be. Use a separate chain with EndpointRequest.toAnyEndpoint() and an admin role, or keep the management port on the internal network.

Secrets: never put client secrets, DB passwords or signing keys in git. Use environment variables, Vault or a cloud secret manager. If a secret leaks into git, rotate it; deleting the file is not enough.

Patching: Spring Security CVEs come out regularly. Keep Boot patch versions current and run OWASP Dependency-Check, Snyk or Dependabot in CI.

Performance: BCrypt makes the login endpoint the first CPU bottleneck, so rate-limit login. JWT validation is cheap and JWKS is cached. Introspection is a network call on every request, so cache it.

Incident checklist (auth suddenly broken):

  1. Are server clocks in sync? (NTP; JWT exp/nbf)
  2. Does the issuer URL match exactly? (http vs https, trailing slash, hostname)
  3. Behind a proxy: are forwarded headers set and trusted?
  4. Can the service reach the JWKS endpoint?
  5. Did the audience or scope change?
  6. Was a signing key or client secret rotated but not deployed? (In dev, did the auth server restart and invalidate every token?)
  7. Is there a new POST endpoint without a CSRF token, or a new frontend origin without CORS?
  8. With multiple instances: is the session store shared?
  9. Did a dependency upgrade change a default? (read the release notes)

15. Common mistakes

Mistake Why it hurts Do instead
csrf().disable() in session/cookie apps CSRF attacks open Disable only for pure header-token APIs
Only a scope check on /resource/{id} IDOR: any user reads any record Ownership check in query or @PreAuthorize
Security only in controller Other entry points bypass Method security in service layer too
hasRole with un-prefixed authorities Everything 403 Map to ROLE_ or use hasAuthority
anyRequest().permitAll() while developing, forgotten Whole app open Default deny: anyRequest().authenticated()
web.ignoring() for APIs No headers, no context permitAll()
Custom filter as @Component Runs twice Not a bean, or disable registration
JWT with HS256 shared across services Any service can forge tokens RS256/ES256 + JWKS
No audience check Token reused across services Configure audiences
Long-lived access tokens (24h+) Stolen token valid long 5–15 min + refresh rotation
Tokens in localStorage XSS steals them BFF or HttpOnly cookie + CSRF
Passwords with MD5/SHA-256 or no prefix Fast to crack / encoder error DelegatingPasswordEncoder (BCrypt/Argon2)
Detailed login errors ("user not found") User enumeration Generic "Bad credentials"
@PostAuthorize on write methods Write happens before check @PreAuthorize
Trusting X-User-Id header from other services Spoofable Signed token
Actuator fully exposed Secrets and heap dumps leak Expose health/info only
Thinking CORS protects the API curl ignores it Real authentication
No tests for security rules Silent regressions Allowed + denied test per rule

16. Pocket summary

All of Spring Security on one card, for your notepad.

SPRING SECURITY = filters before controller

FLOW: Proxy → FilterChainProxy → Chain
      → AuthN filters → AuthZ filter → Controller

AUTHN (who?)  fail → 401
  Filter → AuthManager → Provider
  → UserDetailsService + PasswordEncoder
  → Authentication in SecurityContext (ThreadLocal)

AUTHZ (allowed?) fail → 403
  URL rules (first match) · @PreAuthorize
  · ownership (IDOR!)

STATE
  Session: JSESSIONID, revocable, needs shared store
  JWT: stateless, verify sig/exp/iss/aud,
       can't revoke → short expiry

PROTECT
  CSRF = cookies (disable only for header tokens)
  CORS = browser only
  Headers on by default · HTTPS + forwarded headers

OAUTH2
  Login: auth code + PKCE · Services: client creds
  Every service = resource server (+ audience)

PROD: test allow+deny · no tokens in logs
      · actuator locked · sync clocks · patch