Learning Ladder applied · every concept
Every Spring Security choice: options, failures, tests
One card per concept in the Spring Security Guide. Each card shows why the concept exists, how this project uses it, the options with what you gain and pay, which to pick, what breaks in production and why, and a hypothesis to prove in code.
Step 1 Why it existsStep 2 Hypothesis to testStep 3 Options and trade-offsStep 4 If it fails (and then what?)Step 5 Connects to
§2 ArchitectureWhere security runs: filter, interceptor or AOP
Step 1Why it exists
A request must be checked before any of your code touches it, including error pages, static files and forwards.
How it is used
Spring Security registers one servlet filter (DelegatingFilterProxy) that runs before the DispatcherServlet. Your controllers never see an unauthenticated request.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Servlet filter (Spring Security) | Runs before Spring MVC; covers every URL, /error, static files | Harder to debug; ordering matters |
HandlerInterceptor | Easy access to the controller method | Runs only for mapped handlers; misses static files and some error paths |
| AOP / method checks only | Fine-grained, close to the business rule | Nothing protects URLs that have no annotated method |
Pick: Filters for authentication and URL rules; method security on top for business rules. Never interceptors alone.
Step 4If it fails
| What you see | Why | Fix |
|---|
Static file or /error page leaks data | Security written as an interceptor or only in controllers | Move checks into the SecurityFilterChain |
| Every request is open after an upgrade | Your SecurityFilterChain bean is not picked up (wrong package, missing @Configuration) | Check the startup log for the filter list; add a test that expects 401 |
Step 2Hypothesis to test
An endpoint with no rule of its own returns 401 when called without a token (it falls through to anyRequest().authenticated()).
Step 5Connects to
Course Lecture 7 (request-response: the request is parsed before processing).
§2 ArchitectureOne chain or several chains
Step 1Why it exists
Different parts of one app need different security: a login page needs sessions, an API needs tokens, actuator needs admin only.
How it is used
Each SecurityFilterChain bean gets a securityMatcher and an @Order; FilterChainProxy runs the first chain that matches. The auth server here has @Order(1) for OAuth2 endpoints and @Order(2) for form login.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| One chain, many rules | Simple; one place to read | Cannot mix stateless and session login cleanly |
| One chain per area | Each area gets exactly the filters it needs | Order mistakes send requests to the wrong chain |
| Separate apps | Full isolation | More deployments and config |
Pick: Split chains when two areas need different login styles or session policies; otherwise keep one.
Step 4If it fails
| What you see | Why | Fix |
|---|
API call gets a 302 redirect to /login | The request matched the form-login chain, not the API chain | Give the API chain a securityMatcher and a lower @Order number |
| Startup fails: a chain matching any request is configured | A chain without securityMatcher is not last | Move it to the highest @Order value |
Step 2Hypothesis to test
With two chains, /api/x without a token returns 401 and /web without login returns 302.
Step 5Connects to
Guide §3; this repo's auth server SecurityConfig.
§2 ArchitectureWriting a custom filter
Step 1Why it exists
Some credentials Spring does not support out of the box: API keys, legacy SSO headers, signed webhooks.
How it is used
Extend OncePerRequestFilter, build an Authentication, set it in the context, and add it with http.addFilterBefore(filter, AuthorizationFilter.class).
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Custom filter only | Full control | You handle errors, ordering and tests yourself |
Filter + custom AuthenticationProvider | Fits Spring's design; reuses ProviderManager, events, entry point | More classes |
Built-in support (oauth2ResourceServer, x509) | Battle tested | Only for standard credentials |
Pick: Use built-in support first; if you must write one, filter + provider.
Step 4If it fails
| What you see | Why | Fix |
|---|
| Filter runs twice per request | Annotated @Component, so Boot also registers it with Tomcat | Do not make it a bean, or disable it with FilterRegistrationBean.setEnabled(false) |
| Bad key returns 500 instead of 401 | Exception thrown in the filter never reaches @ControllerAdvice | Throw an AuthenticationException and let the entry point answer |
| Valid key still gets 403 | Filter added after AuthorizationFilter | Add it before AuthorizationFilter |
Step 2Hypothesis to test
A request with a wrong API key returns 401 with your JSON error, and the filter logs once per request.
Step 5Connects to
Guide §2 filter table.
§4 AuthenticationHow to store passwords
Step 1Why it exists
If the user table leaks, attackers must not be able to recover passwords quickly.
How it is used
PasswordEncoderFactories.createDelegatingPasswordEncoder() stores {bcrypt}$2a$10$.... This repo uses withDefaultPasswordEncoder() and {noop} for demo users and clients only.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
Plain text / {noop} | Easy to debug | Leaked table = all passwords leaked |
| Fast hash (SHA-256, MD5) | Fast | Billions of guesses per second on a GPU |
| BCrypt | Default, salted, slow, well supported | Uses only the first 72 bytes; CPU cost per login |
| Argon2 / SCrypt | Memory-hard; resists GPUs better | More memory per login; tune carefully |
Pick: DelegatingPasswordEncoder with BCrypt (or Argon2 for new systems), so you can switch algorithms later.
Step 4If it fails
| What you see | Why | Fix |
|---|
| Login fails: no PasswordEncoder mapped for the id "null" | Stored hash has no {id} prefix | Prefix existing hashes (e.g. {bcrypt}) or set a default encoder for matching |
| Login endpoint CPU at 100% under load | BCrypt strength too high, or password checked on every request (HTTP Basic) | Tune strength (~100 ms per check); log in once, then use a session or token |
Step 2Hypothesis to test
Encoding the same password twice gives two different strings, and matches() returns true for both.
Step 5Connects to
Hypothesis Book B6–B10.
§4 AuthenticationWhich login style
Step 1Why it exists
The way credentials arrive decides which filter handles them and whether the server keeps state.
How it is used
This repo: form login on the auth server, Bearer JWTs on Services A, B and Notification.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Form login + session | Simple for server-rendered apps; easy logout | Stateful; needs CSRF |
| HTTP Basic | Zero setup for scripts | Password on every request; BCrypt cost every time |
| Bearer token (JWT) | Stateless; works for APIs, mobile, services | Revocation is hard |
| OAuth2 / OIDC login | SSO, MFA and password handling live in the identity provider | Redirect flow and an external dependency |
Pick: Web pages: OIDC login (or form login for small apps). APIs: Bearer tokens. Basic only for internal tools over HTTPS.
Step 4If it fails
| What you see | Why | Fix |
|---|
| Mobile app breaks on login | Form login expects HTML redirects | Use authorization code + PKCE in the app, Bearer tokens to the API |
| Latency grows with traffic | HTTP Basic re-checks BCrypt each call | Switch to tokens or sessions |
Step 2Hypothesis to test
Calling Service A with Basic credentials returns 401, because only Bearer tokens are configured.
Step 5Connects to
Course Lecture 15 (stateful vs stateless).
§4 AuthenticationWhere users come from (UserDetailsService)
Step 1Why it exists
Spring needs a username, password hash, status flags and authorities to check a login.
How it is used
This repo uses InMemoryUserDetailsManager with nithin and alice on the auth server.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| In-memory | Instant for demos and tests | Lost on restart; no real users |
JDBC (JdbcUserDetailsManager) | Built-in schema and user management | Fixed table shape unless you customise queries |
JPA (your own UserDetailsService) | Your own entity and rules | You write and test it |
| LDAP / Active Directory | Company accounts | Network dependency; slower |
| External identity provider (OIDC) | No passwords in your app at all | Depends on the provider |
Pick: Real apps: an external identity provider or a JPA-backed service. In-memory only for demos.
Step 4If it fails
| What you see | Why | Fix |
|---|
LazyInitializationException at login | Roles are a lazy collection loaded after the session closed | Fetch roles eagerly or map to a DTO inside the transaction |
Login works for Nithin, fails for nithin | Case-sensitive username lookup | Normalise usernames on save and lookup |
Step 2Hypothesis to test
An unknown username and a wrong password return the same "Bad credentials" error.
Step 5Connects to
Guide §4.3.
§4 AuthenticationStopping password guessing
Step 1Why it exists
Spring Security does not limit login attempts. Without limits, attackers can try passwords forever.
How it is used
Not built in. You build it from AbstractAuthenticationFailureEvent events, a gateway rate limit, or the identity provider.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Rate limit per IP (gateway, Bucket4j) | Cheap; stops scripts | Botnets rotate IPs |
| Account lockout after N failures | Stops guessing on one account | Attackers can lock real users out (denial of service) |
| Progressive delay / CAPTCHA | Slows bots, keeps users in | Friction for users |
| MFA | Password alone is no longer enough | Setup and recovery flows |
Pick: Rate limit per IP and per account, temporary lockout (minutes, not forever), and MFA for sensitive apps.
Step 4If it fails
| What you see | Why | Fix |
|---|
| Many users locked out at once | Attacker triggered permanent lockouts | Use short, automatic unlocks and alert on spikes |
| Thousands of failed logins, nobody noticed | No metric on failure events | Count failure events and alert |
Step 2Hypothesis to test
500 wrong passwords in a script are all processed today (no built-in limit); after adding a limit, request 6 gets 429.
Step 5Connects to
Hypothesis Book B15.
§5 SecurityContextKeeping the user across threads
Step 1Why it exists
The current user lives in a ThreadLocal. A new thread starts with an empty context.
How it is used
SecurityContextHolder (ThreadLocal by default). Service B reads getAuthentication().getName() to find the payment owner.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Default ThreadLocal | Safe; no leaks between requests | @Async and executors lose the user |
DelegatingSecurityContext* wrappers | Copies the context into the task explicitly | Must wrap every executor |
MODE_INHERITABLETHREADLOCAL | Child threads see the user automatically | Pooled threads keep the wrong user |
| Pass the user id as a parameter | Explicit and testable | Changes method signatures |
Pick: Keep ThreadLocal; wrap executors with DelegatingSecurityContextAsyncTaskExecutor, or pass the user id explicitly.
Step 4If it fails
| What you see | Why | Fix |
|---|
NullPointerException on getAuthentication() in an @Async method | New thread, empty context | Wrap the executor |
| Payment saved under another user's name | Inheritable mode with a thread pool reused a thread | Go back to ThreadLocal + wrappers |
Step 2Hypothesis to test
Inside an @Async method getAuthentication() is null; after wrapping the executor it returns the caller's name.
Step 5Connects to
Hypothesis Book C2–C4.
§5 SecurityContextIdentity for jobs with no user
Step 1Why it exists
Schedulers and message listeners have no HTTP request and no logged-in user, but still call secured code.
How it is used
The settlement job gets its own token with client credentials, using AuthorizedClientServiceOAuth2AuthorizedClientManager.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Own token (client credentials) | Real identity, scopes, audit trail | Needs a registered client |
Run-as system Authentication in the context | Works for in-process method security | Easy to grant too much |
| Skip security for internal calls | No setup | Any bug becomes a bypass |
Pick: Client credentials for calls to other services; a narrow system authentication for in-process checks.
Step 4If it fails
| What you see | Why | Fix |
|---|
servletRequest cannot be null from the OAuth2 client | DefaultOAuth2AuthorizedClientManager used outside a request | Use AuthorizedClientServiceOAuth2AuthorizedClientManager |
AuthenticationCredentialsNotFoundException in a scheduled job | @PreAuthorize service called with an empty context | Set a system authentication for the job |
Step 2Hypothesis to test
The settlement job calls Service B every minute with sub = settlement-job, with no user logged in.
Step 5Connects to
Repo: code/settlement-job/OAuth2ClientConfig.java.
§6 SessionsSession or token (stateful or stateless)
Step 1Why it exists
HTTP forgets every request. Either the server remembers the user, or every request carries proof.
How it is used
This repo: the auth server keeps a session for login; Services A, B and Notification are STATELESS with JWTs.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
Server session (JSESSIONID) | Instant logout and revoke; small cookie | Server memory; must share across instances |
| Self-contained token (JWT) | No server memory; any instance can verify | Cannot revoke before expiry |
| Opaque token + introspection | Revocable and stateless for the API | A call to the auth server per request |
Pick: Browser apps: session (or BFF). APIs and service-to-service: short-lived JWT.
Step 4If it fails
| What you see | Why | Fix |
|---|
| User stays logged in after being disabled | JWT still valid until exp | Short expiry, or introspection for sensitive APIs |
| Random logouts behind a load balancer | Sessions live on one server | See "Sessions across servers" |
Step 2Hypothesis to test
After logout on the auth server, an old access token still works on Service A until it expires (5 minutes).
Step 5Connects to
Course Lecture 15 (stateful vs stateless).
§7 AuthorizationWhere to check permissions
Step 1Why it exists
A rule in the wrong place can be bypassed by another entry point or by a new endpoint.
How it is used
This repo checks scopes on URLs (hasAuthority("SCOPE_payment.write")) and ownership in the service/query.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| URL rules in the filter chain | One place; covers every URL | Knows nothing about the record or business rules |
Method security (@PreAuthorize) | Close to the business logic; protects every caller | Proxy limits: self-calls and private methods skip it |
In the query (findByIdAndOwner) | Impossible to fetch someone else's data | Rules spread across repositories |
Pick: All three: URL for coarse access, method for business rules, query for ownership.
Step 4If it fails
| What you see | Why | Fix |
|---|
| New endpoint open to every logged-in user | Only anyRequest().authenticated() covers it | Default to deny; add a test per endpoint |
@PreAuthorize ignored | Called from the same class, or @EnableMethodSecurity missing | Call through another bean; enable method security |
Step 2Hypothesis to test
A USER calling a @PreAuthorize("hasRole('ADMIN')") service method from a scheduler (no proxy bypass) gets AccessDeniedException.
Step 5Connects to
Guide §7.3; Hypothesis Book D5–D7.
§7 AuthorizationRoles, authorities or scopes
Step 1Why it exists
You need a name for "what this caller may do", and the name decides which check you write.
How it is used
This repo uses scopes from the JWT: SCOPE_payment.read, SCOPE_payment.write, SCOPE_notification.send.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
Roles (ROLE_ADMIN) | Simple for user groups | Coarse; tends to explode into many roles |
Fine authorities (payment.refund) | Precise permissions | More to manage |
OAuth2 scopes (SCOPE_x) | What the client was allowed to do on the user's behalf | Not the same as what the user may do |
Pick: Scopes to limit the client, roles/authorities to describe the user, and check both for sensitive actions.
Step 4If it fails
| What you see | Why | Fix |
|---|
Every hasRole check returns 403 | Authorities lack the ROLE_ prefix, or JWT roles are not mapped | Map claims with a JwtAuthenticationConverter, or use hasAuthority |
| A low-privilege user does admin actions | Only the client's scope was checked, not the user's role | Check the user's role too |
Step 2Hypothesis to test
A token with payment.read only gets 403 on POST /payments, and 200 on GET.
Step 5Connects to
Guide §7.1, §9.3.
§7 AuthorizationWhat 401 and 403 look like
Step 1Why it exists
Clients need to tell "log in again" from "you may not do this", in a format they can parse.
How it is used
ExceptionTranslationFilter sends 401 through the AuthenticationEntryPoint and 403 through the AccessDeniedHandler.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Defaults | No code | Browsers get HTML redirects; APIs get empty or HTML bodies |
| Custom JSON entry point + denied handler | Same error contract as the rest of the API | Two small classes |
ProblemDetail (RFC 9457) | Standard error format | Clients must understand it |
Pick: APIs: custom JSON or ProblemDetail, matching the app's GlobalExceptionHandler format.
Step 4If it fails
| What you see | Why | Fix |
|---|
| Frontend crashes parsing HTML | API returned the login page with 200 or 302 | Return 401 JSON for API requests |
| Security errors look different from other errors | @ControllerAdvice never sees filter exceptions | Format them in the entry point and denied handler |
Step 2Hypothesis to test
GET /payments/1 without a token returns 401 with a WWW-Authenticate: Bearer header.
Step 5Connects to
Guide §7.7.
§8 Exploit protectionCSRF: keep it or turn it off
Step 1Why it exists
Browsers attach cookies automatically, so another site can make the user's browser send a request with their session.
How it is used
This repo disables CSRF on Services A, B and Notification, because they only accept Bearer tokens in a header.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Keep CSRF on (default) | Protects cookie-based logins | Clients must send the token |
| Cookie-based token for SPAs | Works with JavaScript frontends | Extra frontend and handler config in Security 6 |
| Disable | Simple for header-token APIs | Dangerous the moment you use cookies for auth |
Pick: Disable only when authentication never uses cookies. Keep it for anything with a session cookie.
Step 4If it fails
| What you see | Why | Fix |
|---|
| GET works but POST returns 403 | CSRF on and no token sent | Send the token, or disable it if the API is header-token only |
| SPA POSTs fail after upgrading to Security 6 | Token masking changed how the token is read | Configure the SPA request handler for your version |
| Attacker's page transfers money | CSRF disabled while using a session or JWT cookie | Turn CSRF back on |
Step 2Hypothesis to test
On a session app, POST without _csrf returns 403; on Service A, POST with a valid Bearer token and no CSRF token returns 200.
Step 5Connects to
Hypothesis Book E1–E6.
§8 Exploit protectionCORS configuration
Step 1Why it exists
Browsers block JavaScript from one origin calling another unless the server allows it.
How it is used
Not configured in this repo, because calls come from Postman, PowerShell and other services, not a browser frontend.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| No CORS | Nothing to configure | Browser frontends on other origins cannot call the API |
| Exact allowed origins | Safe and explicit | List must be maintained |
* or reflect any origin | Everything works | Any site can call with the user's credentials if cookies are used |
Pick: Exact origins via http.cors() + CorsConfigurationSource when a browser frontend exists.
Step 4If it fails
| What you see | Why | Fix |
|---|
| Browser console: blocked by CORS policy; preflight 401 | OPTIONS reached the security filters without http.cors() | Enable http.cors() so preflights are answered first |
| Startup or request error with credentials | allowCredentials(true) with origin * | List exact origins or use allowedOriginPatterns |
Step 2Hypothesis to test
curl calls succeed with no CORS config; a browser fetch from another origin fails until you add it.
Step 5Connects to
Guide §8.2.
§8 Exploit protectionHTTPS behind a proxy or load balancer
Step 1Why it exists
TLS usually ends at the load balancer, so the app sees plain HTTP and can build wrong URLs.
How it is used
Locally everything is HTTP on localhost; in production a proxy would terminate TLS.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| TLS ends at the proxy, app trusts forwarded headers | Common, simple certificates | App must be told the original scheme and host |
| TLS all the way to the app | Encrypted inside the network too | Certificates on every service |
| Service mesh mTLS | Encrypted and authenticated between services | Infrastructure heavy |
Pick: Terminate at the proxy, set server.forward-headers-strategy, and trust only your proxy. Add mTLS inside when required.
Step 4If it fails
| What you see | Why | Fix |
|---|
| OAuth2 login fails with invalid redirect_uri | App builds http:// URLs behind an HTTPS proxy | Configure forwarded headers |
| Login redirects users to http | Same cause | Same fix; add HSTS |
Attacker spoofs X-Forwarded-For | App trusts headers from anyone | Trust forwarded headers only from the proxy |
Step 2Hypothesis to test
Behind Nginx with HTTPS and no forwarded headers, the OAuth2 redirect_uri starts with http://.
Step 5Connects to
Course Lectures 23 and 51 (TLS; proxy vs reverse proxy).
§9 JWTWhere the signing key lives and how it rotates
Step 1Why it exists
If the key is lost, every token dies; if it leaks, anyone can mint tokens.
How it is used
Dev profile: a new RSA key in memory on every start. Other profiles: a PKCS12 keystore from KEYSTORE_PATH.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Generated in memory | Zero setup | Restart = every token invalid; each instance has a different key |
| Keystore file | Stable across restarts | File must be protected and distributed |
| KMS / HSM / Vault | Key never leaves secure hardware; audited | Cost and setup |
Pick: Keystore or KMS in production; publish old and new keys in JWKS during rotation.
Step 4If it fails
| What you see | Why | Fix |
|---|
| Everyone gets 401 after the auth server restarts | Dev in-memory key regenerated | Expected in dev; use the keystore elsewhere |
| About half the requests fail with 401 | Two auth server instances, each with its own generated key | Share one key (keystore/KMS) across instances |
| Tokens fail right after rotation | Old key removed from JWKS before old tokens expired | Keep the old key published until the longest token lifetime passes |
Step 2Hypothesis to test
Restart the auth server in dev: an old token now returns 401 on Service A.
Step 5Connects to
Repo: auth server devJwkSource / jwkSource.
Step 1Why it exists
Logout, password change or a fired employee must stop access, but a JWT stays valid until it expires.
How it is used
This repo keeps access tokens to 5 minutes (accessTokenTimeToLive).
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Short expiry + refresh token | Mostly stateless; access ends within minutes | Refresh store and rotation |
Deny-list of jti in Redis | Instant | A lookup per request |
| Opaque token + introspection | Instant | Auth server call per request |
Pick: 5–15 minute access tokens with rotating refresh tokens; deny-list only for high-risk cases.
Step 4If it fails
| What you see | Why | Fix |
|---|
| Fired user keeps access for a day | 24-hour access tokens | Shorten expiry |
| Users logged out every 5 minutes | Short tokens without refresh | Add refresh tokens |
Step 2Hypothesis to test
A token copied before logout still works on Service A until its exp.
Step 5Connects to
Hypothesis Book F11, F17.
§10 OAuth2Which authorization server
Step 1Why it exists
Someone must issue, sign and rotate tokens, and handle logins, consent and MFA.
How it is used
This repo runs Spring Authorization Server 1.5 on port 9000.
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Write your own token endpoint | Full control | You own every security bug |
| Spring Authorization Server | Java, customisable, same stack | You run, patch and scale it |
| Keycloak | Feature-rich, admin UI, open source | Separate product to operate |
| SaaS (Okta, Auth0, Entra ID) | No servers; MFA and compliance included | Cost; vendor lock-in |
Pick: Learning and custom needs: Spring Authorization Server. Company production: Keycloak or a SaaS provider.
Step 4If it fails
| What you see | Why | Fix |
|---|
| Every service fails to get new tokens | Single auth server instance down | Run it highly available |
| Tokens rejected after moving the auth server | issuer URL changed (iss mismatch) | Keep the issuer URL stable |
Step 2Hypothesis to test
Stop the auth server: Service A still accepts unexpired tokens, but new logins fail.
Step 5Connects to
Guide §9.7, §10.4.
§14 ProductionExposing actuator endpoints
Step 1Why it exists
Actuator can reveal environment variables, heap dumps (with secrets in memory) and config.
How it is used
Services A and B permit /actuator/health; Notification exposes health,mappings (mappings needs a token).
Step 3Options and trade-offs
| Option | You gain | You pay |
|---|
| Health only, public | Load balancer checks work | Little insight |
| More endpoints behind an admin role / separate chain | Useful operations data | Must protect correctly |
| Management port on the internal network | Not reachable from outside | Network setup |
Pick: Public health; everything else on a separate port or chain with admin access.
Step 4If it fails
| What you see | Why | Fix |
|---|
Passwords visible at /actuator/env or in a heap dump | Endpoints exposed without auth | Restrict exposure and secure them |
Step 2Hypothesis to test
curl /actuator/env without a token returns 401 or 404, never 200.
Step 5Connects to
Hypothesis Book H8.
No card matches. Clear the search or pick All.