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:
- HTTP is stateless. The server has no memory, so every request must carry proof (a session cookie or a token).
- 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
Where to see it in this project
| Topic | File in this repo | What 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.java | SCOPE_payment.read / write per HTTP method, CSRF off, no sessions |
| Ownership, 404 not 403 (§7.6) | payment-service-b/…/Service/PaymentServiceImpl.java | Owner taken from the JWT sub via SecurityContextHolder |
| Client credentials (§10, §11) | code/settlement-job/…/config/OAuth2ClientConfig.java | Token for a @Scheduled job with no HTTP request |
| Deny everything (§7.2) | code/settlement-job/…/config/SecurityConfig.java | anyRequest().denyAll() for an outbound-only app |
| Dev-only chain (§8.3) | payment-service-b/…/Config/SecurityConfig.java | H2 console chain with frameOptions().sameOrigin(), @Profile("dev") |
| Signing keys (§9.4) | payment-authorization-server/…/Config/SecurityConfig.java | RSA 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:
- Tomcat filter chain: the normal filters in the servlet container.
DelegatingFilterProxy: Tomcat does not know about Spring beans. This proxy registers as a Tomcat filter and hands the work to the Spring beanspringSecurityFilterChain.FilterChainProxy: that bean. It holds everySecurityFilterChainin your app and picks the first chain that matches the request. It clears theSecurityContextafter the request and rejects malformed URLs (such as..or encoded slashes) through theHttpFirewall.SecurityFilterChain: an ordered list of security filters, built from yourHttpSecurityconfig.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
AuthenticationManager: an interface with one method,authenticate().ProviderManager: the main implementation. It holds a list ofAuthenticationProviders, asks each one whether it supports this token type, and hands the token to the first one that does. If none supports it, it throwsProviderNotFoundException. If it has a parent manager, it falls back to that.AuthenticationProvider: does the actual check. Examples:DaoAuthenticationProvider(username/password),JwtAuthenticationProvider,OidcAuthorizationCodeAuthenticationProvider, or your own provider (LDAP, OTP, API key).DaoAuthenticationProvidersteps: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
- Never store plain text or reversible encryption. Use only a slow, salted, one-way hash.
- BCrypt is the default (strength 10). Argon2, SCrypt and PBKDF2 are also available. The slowness is the point: it makes guessing expensive for an attacker.
DelegatingPasswordEncoder: the hash carries a prefix such as{bcrypt},{argon2}or{noop}. Changing algorithms does not break old users, andUserDetailsPasswordServicecan upgrade a hash automatically at login.- The same password gives a different hash every time (random salt);
matches()still compares correctly. - Cost: one BCrypt check takes about 50–100 ms. That is why checking a password on every request (HTTP Basic) is bad under high traffic; log in once, then use a session or token.
- In this project,
User.withDefaultPasswordEncoder()and{noop}client secrets are fine for a demo, but never for production.
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
- Success:
AuthenticationSuccessHandler(default: redirect to the saved request). For REST, write a custom handler that returns JSON. - Failure:
AuthenticationFailureHandler(default:/login?error). - Events:
AuthenticationSuccessEventand subclasses ofAbstractAuthenticationFailureEventare published. Use@EventListenerfor audit logs, failed-login counters and alerts. - Account lockout / brute force: not built in. Use failure events + a counter +
isAccountNonLocked, or rate limiting at the gateway. - Logout: POST
/logout(with a token when CSRF is on).LogoutFilterinvalidates the session, clears the context, deletes cookies (deleteCookies) and clears remember-me. A JWT is stateless, so logout does not kill it (section 9).
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:
SecurityContextHolderFilterloads the context from theSecurityContextRepository(for sessions, theSPRING_SECURITY_CONTEXTattribute in theHttpSession).- On a new login, the authentication filters set the context and save it to the repository.
- 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):
@Async,CompletableFuture.supplyAsync()andExecutorServicedo not carry the context, so the user isnull.- Fix: wrap the executor with
DelegatingSecurityContextAsyncTaskExecutor/DelegatingSecurityContextExecutorService, or useDelegatingSecurityContextRunnable. - Spring MVC
Callable/DeferredResultare handled byWebAsyncManagerIntegrationFilter. @Scheduledjobs and Kafka listeners have no HTTP request, so there is no user. That is why the settlement job usesAuthorizedClientServiceOAuth2AuthorizedClientManager: the default manager needs an HTTP request.- With virtual threads (Java 21), the per-request ThreadLocal still works.
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
- A
GrantedAuthorityis one permission string, for exampleorders.readorSCOPE_payment.read. - A role is an authority with the
ROLE_prefix.hasRole("ADMIN")actually checks forROLE_ADMIN;hasAuthority("ADMIN")checks for exactlyADMIN. User.withUsername(..).roles("ADMIN")adds the prefix;authorities("ROLE_ADMIN")uses the string as is.- Good design: coarse roles (ADMIN, USER) and fine-grained authorities (
orders.delete), with roles mapped to authorities. This project uses scopes only:SCOPE_payment.read,SCOPE_payment.write,SCOPE_notification.send.
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:
- Filter by owner in the query:
findByIdAndOwnerId(id, currentUserId). @PreAuthorize("@paymentSecurity.isOwner(#id, authentication)").@PostAuthorizeon read methods.- Multi-tenant: take the tenant id from the token and apply it in every query (Hibernate filter or row-level security).
- The Spring Security ACL module: per-object permission tables (heavy; for rare cases).
7.7 401 vs 403 handling
ExceptionTranslationFilter catches the exceptions:
- An
AuthenticationException, or access denied for an anonymous user →AuthenticationEntryPoint→ 401 (or a redirect to the login page, or aWWW-Authenticateheader). - An
AccessDeniedExceptionfor an authenticated user →AccessDeniedHandler→ 403.
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.
- Server-rendered forms: Thymeleaf adds the hidden
_csrffield automatically. - SPA:
CookieCsrfTokenRepository.withHttpOnlyFalse(); JavaScript reads the cookie and sends it in theX-XSRF-TOKENheader. Because Security 6 masks tokens (BREACH protection), an SPA also needs request-handler config; check your version's docs. - When you may disable it: when authentication is only a token in the
Authorizationheader (browsers never attach it automatically) and no cookies are used. That is why Services A, B and Notification disable CSRF. If you put a JWT in a cookie, you need CSRF again. SameSite=Lax/Strictcookies are an extra layer, not a replacement.- Common confusion: "GET works, POST returns 403" is almost always CSRF.
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.
http.cors(Customizer.withDefaults())+ aCorsConfigurationSourcebean: allowed origins, methods, headers and credentials.- Without
http.cors(), the preflight fails in the security filters with 401/403. allowCredentials(true)cannot be combined with origin*; use exact origins orallowedOriginPatterns.- Do not treat CORS as protection for your API; authentication is the protection.
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
- Without HTTPS, passwords, cookies and tokens travel as plain text. Always use HTTPS in production.
- TLS usually ends at a load balancer or Nginx, and the app receives HTTP. If the app does not know the original request was HTTPS, redirects, OAuth2
redirect_uriand secure cookies break. Fix:server.forward-headers-strategy=native(orframework), have the proxy setX-Forwarded-Proto/Host, and trust only your own proxy.
8.5 What Spring Security does not do (your job)
- Rate limiting / brute-force protection (Bucket4j, gateway, WAF).
- Input validation (
@Valid, asPaymentControllerdoes) and SQL injection prevention (JPA parameters, never string concatenation). - XSS output encoding (Thymeleaf and React escape by default; avoid raw HTML).
- Secrets management (Vault, environment variables, cloud secret managers). The Notification Service already reads Gmail credentials from environment variables.
- Audit logging (build it from the events).
- Dependency patching (CVE scans).
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.
- Header:
alg(RS256, ES256, HS256) andkid(which key signed it). - Payload (claims):
iss(issuer),sub(user),aud(which client or service it is for),exp(expiry),iat,nbf,scope, and custom claims (roles,tenant_id). - Signature: made with the issuer's private key. Anyone can read the payload (it is not encrypted), but nobody can change it. Never put passwords or personal data in a JWT.
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
- Default: only the
scope/scpclaim becomesSCOPE_xxxauthorities. That is what this project's rules use. - Keycloak roles (
realm_access.roles), Azure ADrolesand customrolesclaims are not mapped automatically. Write a customJwtAuthenticationConverter/JwtGrantedAuthoritiesConverterthat adds theROLE_prefix. WhenhasRoledoes not work, this is the cause 90% of the time. - Audience is not checked by default. Spring Authorization Server puts the requesting client's id in
aud. Settingaudienceslets a service accept tokens only from the clients you expect (for example, Notification only frompayment-service-b).
9.4 Keys and rotation
- Asymmetric (RS256/ES256): the auth server signs with a private key; services verify with the public key from the JWKS URL. Services cannot create tokens. Recommended, and what this project uses.
- Symmetric (HS256): a shared secret. Every service holding the secret can forge tokens. Use it only within a single app.
- JWKS keys are cached. When a token arrives with an unknown
kid, Spring fetches the JWKS again, so key rotation needs no restart. - In the
devprofile the auth server generates a new RSA key on every start, so old tokens die on restart; other profiles load a fixed key from a PKCS12 keystore. - With
issuer-uri, the decoder is built lazily: the app starts even if the auth server is down, and fails on the first request.
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?
localStorage: JavaScript injected through XSS can steal it.- An in-memory JavaScript variable: lost on refresh; lower XSS risk, but not zero.
- An
HttpOnlycookie: JavaScript cannot read it, but you need CSRF protection. - BFF (Backend-for-Frontend) pattern: tokens live only on the server (gateway); the browser holds only a session cookie. The current best practice for SPAs.
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
- Access token: used to call APIs; read by the resource server. Short-lived.
- Refresh token: used to get a new access token. Long-lived; the client keeps it secret.
- ID token (OIDC): for the client only, carries user info (a JWT). Never send it to APIs.
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
oauth2Login()(client, user login): the flow starts at/oauth2/authorization/{registrationId}and the callback is/login/oauth2/code/{id}. The result is anOAuth2User/OidcUserprincipal saved in the session. Map roles with aGrantedAuthoritiesMapperor a customOidcUserService.oauth2Client()(client, calling APIs):OAuth2AuthorizedClientManagergets, caches and refreshes tokens.RestClient/WebClientinterceptors add theBearerheader automatically. Client credentials live here (see the settlement job'sOAuth2ClientConfig).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
- 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.
- Gateway → services: forwards the user's access token (token relay, as Service A does), or a narrower token the gateway exchanged for.
- 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).
- Every service is a resource server: do not skip checks because the gateway already checked. Verify signature,
iss,audand 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
- Check the audience in every service. A token issued for one client must not work everywhere.
- Least-privilege scopes for every client (
payment-service-bonly hasnotification.send). - The auth server is a critical dependency: run it highly available and monitor it. Client credentials tokens are cached, so a short outage is tolerated; a long one is not.
- Never pass user identity in a plain header (
X-User-Id) without a signature; another service can fake it. - Async (Kafka) messages: there is no HTTP token. Put the user id and producer identity in the message and authorize on the consumer side; use signed claims for sensitive data.
- Secrets: keep client secrets in a vault or environment variables, with a rotation plan.
11.4 Spring pieces
- Calling side:
spring-boot-starter-oauth2-client+OAuth2AuthorizedClientManager+ aRestClient/WebClientinterceptor (client credentials or relay). - Receiving side:
spring-boot-starter-oauth2-resource-server+ audience and scope rules. - Gateway: the Spring Cloud Gateway
TokenRelayfilter.
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):
- Are server clocks in sync? (NTP; JWT
exp/nbf) - Does the issuer URL match exactly? (http vs https, trailing slash, hostname)
- Behind a proxy: are forwarded headers set and trusted?
- Can the service reach the JWKS endpoint?
- Did the audience or scope change?
- Was a signing key or client secret rotated but not deployed? (In
dev, did the auth server restart and invalidate every token?) - Is there a new POST endpoint without a CSRF token, or a new frontend origin without CORS?
- With multiple instances: is the session store shared?
- 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