Spring Security Decision Cards
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 Architecture

Where 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

OptionYou gainYou pay
Servlet filter (Spring Security)Runs before Spring MVC; covers every URL, /error, static filesHarder to debug; ordering matters
HandlerInterceptorEasy access to the controller methodRuns only for mapped handlers; misses static files and some error paths
AOP / method checks onlyFine-grained, close to the business ruleNothing 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 seeWhyFix
Static file or /error page leaks dataSecurity written as an interceptor or only in controllersMove checks into the SecurityFilterChain
Every request is open after an upgradeYour 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 Architecture

One 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

OptionYou gainYou pay
One chain, many rulesSimple; one place to readCannot mix stateless and session login cleanly
One chain per areaEach area gets exactly the filters it needsOrder mistakes send requests to the wrong chain
Separate appsFull isolationMore 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 seeWhyFix
API call gets a 302 redirect to /loginThe request matched the form-login chain, not the API chainGive the API chain a securityMatcher and a lower @Order number
Startup fails: a chain matching any request is configuredA chain without securityMatcher is not lastMove 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 Architecture

Writing 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

OptionYou gainYou pay
Custom filter onlyFull controlYou handle errors, ordering and tests yourself
Filter + custom AuthenticationProviderFits Spring's design; reuses ProviderManager, events, entry pointMore classes
Built-in support (oauth2ResourceServer, x509)Battle testedOnly for standard credentials

Pick: Use built-in support first; if you must write one, filter + provider.

Step 4If it fails

What you seeWhyFix
Filter runs twice per requestAnnotated @Component, so Boot also registers it with TomcatDo not make it a bean, or disable it with FilterRegistrationBean.setEnabled(false)
Bad key returns 500 instead of 401Exception thrown in the filter never reaches @ControllerAdviceThrow an AuthenticationException and let the entry point answer
Valid key still gets 403Filter added after AuthorizationFilterAdd 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 Authentication

How 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

OptionYou gainYou pay
Plain text / {noop}Easy to debugLeaked table = all passwords leaked
Fast hash (SHA-256, MD5)FastBillions of guesses per second on a GPU
BCryptDefault, salted, slow, well supportedUses only the first 72 bytes; CPU cost per login
Argon2 / SCryptMemory-hard; resists GPUs betterMore 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 seeWhyFix
Login fails: no PasswordEncoder mapped for the id "null"Stored hash has no {id} prefixPrefix existing hashes (e.g. {bcrypt}) or set a default encoder for matching
Login endpoint CPU at 100% under loadBCrypt 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 Authentication

Which 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

OptionYou gainYou pay
Form login + sessionSimple for server-rendered apps; easy logoutStateful; needs CSRF
HTTP BasicZero setup for scriptsPassword on every request; BCrypt cost every time
Bearer token (JWT)Stateless; works for APIs, mobile, servicesRevocation is hard
OAuth2 / OIDC loginSSO, MFA and password handling live in the identity providerRedirect 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 seeWhyFix
Mobile app breaks on loginForm login expects HTML redirectsUse authorization code + PKCE in the app, Bearer tokens to the API
Latency grows with trafficHTTP Basic re-checks BCrypt each callSwitch 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 Authentication

Where 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

OptionYou gainYou pay
In-memoryInstant for demos and testsLost on restart; no real users
JDBC (JdbcUserDetailsManager)Built-in schema and user managementFixed table shape unless you customise queries
JPA (your own UserDetailsService)Your own entity and rulesYou write and test it
LDAP / Active DirectoryCompany accountsNetwork dependency; slower
External identity provider (OIDC)No passwords in your app at allDepends 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 seeWhyFix
LazyInitializationException at loginRoles are a lazy collection loaded after the session closedFetch roles eagerly or map to a DTO inside the transaction
Login works for Nithin, fails for nithinCase-sensitive username lookupNormalise 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 Authentication

Stopping 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

OptionYou gainYou pay
Rate limit per IP (gateway, Bucket4j)Cheap; stops scriptsBotnets rotate IPs
Account lockout after N failuresStops guessing on one accountAttackers can lock real users out (denial of service)
Progressive delay / CAPTCHASlows bots, keeps users inFriction for users
MFAPassword alone is no longer enoughSetup 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 seeWhyFix
Many users locked out at onceAttacker triggered permanent lockoutsUse short, automatic unlocks and alert on spikes
Thousands of failed logins, nobody noticedNo metric on failure eventsCount 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 SecurityContext

Keeping 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

OptionYou gainYou pay
Default ThreadLocalSafe; no leaks between requests@Async and executors lose the user
DelegatingSecurityContext* wrappersCopies the context into the task explicitlyMust wrap every executor
MODE_INHERITABLETHREADLOCALChild threads see the user automaticallyPooled threads keep the wrong user
Pass the user id as a parameterExplicit and testableChanges method signatures

Pick: Keep ThreadLocal; wrap executors with DelegatingSecurityContextAsyncTaskExecutor, or pass the user id explicitly.

Step 4If it fails

What you seeWhyFix
NullPointerException on getAuthentication() in an @Async methodNew thread, empty contextWrap the executor
Payment saved under another user's nameInheritable mode with a thread pool reused a threadGo 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 SecurityContext

Identity 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

OptionYou gainYou pay
Own token (client credentials)Real identity, scopes, audit trailNeeds a registered client
Run-as system Authentication in the contextWorks for in-process method securityEasy to grant too much
Skip security for internal callsNo setupAny 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 seeWhyFix
servletRequest cannot be null from the OAuth2 clientDefaultOAuth2AuthorizedClientManager used outside a requestUse AuthorizedClientServiceOAuth2AuthorizedClientManager
AuthenticationCredentialsNotFoundException in a scheduled job@PreAuthorize service called with an empty contextSet 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 Sessions

Session 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

OptionYou gainYou pay
Server session (JSESSIONID)Instant logout and revoke; small cookieServer memory; must share across instances
Self-contained token (JWT)No server memory; any instance can verifyCannot revoke before expiry
Opaque token + introspectionRevocable and stateless for the APIA 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 seeWhyFix
User stays logged in after being disabledJWT still valid until expShort expiry, or introspection for sensitive APIs
Random logouts behind a load balancerSessions live on one serverSee "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).

§6 Sessions

Sessions across several servers

Step 1Why it exists

An in-memory session lives on one server. When the load balancer sends the user to another server, they are logged out.

How it is used

spring-session-data-redis (or -jdbc) replaces the in-memory HttpSession transparently.

Step 3Options and trade-offs

OptionYou gainYou pay
Sticky sessions (load balancer pins a user to a server)No code changeServer dies or redeploys = its users logged out; uneven load
Spring Session + RedisShared, fast, survives restartsRedis becomes a critical dependency
Spring Session + JDBCUses the database you already haveExtra DB load per request
Stateless tokensNo shared store at allRevocation problem

Pick: Spring Session + Redis for browser apps on more than one instance; tokens for APIs.

Step 4If it fails

What you seeWhyFix
Every other request returns 401Two instances, no shared store, no stickinessAdd Spring Session or sticky sessions
Everyone logged out during a deploySticky sessions, server restartedMove to a shared store
All logins fail with 500Redis downRun Redis with replication; alert on Redis health
NotSerializableException on loginPrincipal or session attribute is not SerializableMake stored classes Serializable or use JSON serialization

Step 2Hypothesis to test

Two instances behind Nginx round robin: without Spring Session the second request is 401; with Redis both are 200.

Step 5Connects to

Course Lectures 15 and 52 (stateful vs stateless; L4 vs L7 load balancers).

§6 Sessions

Session fixation protection

Step 1Why it exists

An attacker can plant a known session id in the victim's browser; if it survives login, the attacker shares the session.

How it is used

On by default: Spring changes the session id at login (changeSessionId).

Step 3Options and trade-offs

OptionYou gainYou pay
changeSessionId (default)Keeps attributes, new idNone in practice
migrateSessionNew session, copies attributesSlightly slower
newSessionClean sessionLoses attributes such as a shopping cart
noneNothingVulnerable

Pick: Keep the default.

Step 4If it fails

What you seeWhyFix
Attacker logged in as the victimProtection set to noneRestore the default
Cart empty after loginnewSession drops attributesUse changeSessionId

Step 2Hypothesis to test

The JSESSIONID value in DevTools changes the moment you log in.

Step 5Connects to

Hypothesis Book C7, E13.

§6 Sessions

Limiting sessions per user

Step 1Why it exists

Some apps (banking, licences) allow one active login per user.

How it is used

sessionManagement(s -> s.maximumSessions(1)) plus an HttpSessionEventPublisher bean.

Step 3Options and trade-offs

OptionYou gainYou pay
No limit (default)SimpleShared accounts and stolen sessions go unnoticed
New login expires the old oneUser always gets inAttacker can kick the user out
New login is blocked (maxSessionsPreventsLogin)Protects the active sessionUser locked out if they lose the old session

Pick: Only when the business needs it; prefer expiring the old session and showing "logged in elsewhere".

Step 4If it fails

What you seeWhyFix
User cannot log in again after closing the browserBlocking mode, old session never expiredAdd HttpSessionEventPublisher; use expire mode
Limit ignored across instancesSessionRegistry is in-memory per instanceUse Spring Session's SpringSessionBackedSessionRegistry

Step 2Hypothesis to test

Logging in from a second browser makes the first browser's next request expire.

Step 5Connects to

Hypothesis Book C10–C11.

§7 Authorization

Where 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

OptionYou gainYou pay
URL rules in the filter chainOne place; covers every URLKnows nothing about the record or business rules
Method security (@PreAuthorize)Close to the business logic; protects every callerProxy limits: self-calls and private methods skip it
In the query (findByIdAndOwner)Impossible to fetch someone else's dataRules spread across repositories

Pick: All three: URL for coarse access, method for business rules, query for ownership.

Step 4If it fails

What you seeWhyFix
New endpoint open to every logged-in userOnly anyRequest().authenticated() covers itDefault to deny; add a test per endpoint
@PreAuthorize ignoredCalled from the same class, or @EnableMethodSecurity missingCall 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 Authorization

Roles, 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

OptionYou gainYou pay
Roles (ROLE_ADMIN)Simple for user groupsCoarse; tends to explode into many roles
Fine authorities (payment.refund)Precise permissionsMore to manage
OAuth2 scopes (SCOPE_x)What the client was allowed to do on the user's behalfNot 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 seeWhyFix
Every hasRole check returns 403Authorities lack the ROLE_ prefix, or JWT roles are not mappedMap claims with a JwtAuthenticationConverter, or use hasAuthority
A low-privilege user does admin actionsOnly the client's scope was checked, not the user's roleCheck 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 Authorization

Ownership checks (IDOR): 403 or 404

Step 1Why it exists

Being logged in with the right scope does not mean the record is yours.

How it is used

Service B reads the owner from the JWT sub and returns 404 when the payment belongs to someone else.

Step 3Options and trade-offs

OptionYou gainYou pay
Return 403Honest; easier to debugConfirms the id exists (lets attackers enumerate)
Return 404Hides whether the record existsSupport and debugging are harder
Filter in the queryData never loadedMust be applied to every query

Pick: Filter in the query and return 404 for other users' records, as this repo does.

Step 4If it fails

What you seeWhyFix
User A reads user B's paymentOnly scope checked, no owner filterLook up by id AND owner
A list endpoint shows everyone's paymentsOwner filter applied to getById but not getAllApply the filter in every query; test both

Step 2Hypothesis to test

Alice asking for Nithin's payment id gets 404, the same as for an id that does not exist.

Step 5Connects to

Repo: PaymentServiceImpl; OWASP API1.

§7 Authorization

What 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

OptionYou gainYou pay
DefaultsNo codeBrowsers get HTML redirects; APIs get empty or HTML bodies
Custom JSON entry point + denied handlerSame error contract as the rest of the APITwo small classes
ProblemDetail (RFC 9457)Standard error formatClients must understand it

Pick: APIs: custom JSON or ProblemDetail, matching the app's GlobalExceptionHandler format.

Step 4If it fails

What you seeWhyFix
Frontend crashes parsing HTMLAPI returned the login page with 200 or 302Return 401 JSON for API requests
Security errors look different from other errors@ControllerAdvice never sees filter exceptionsFormat 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 protection

CSRF: 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

OptionYou gainYou pay
Keep CSRF on (default)Protects cookie-based loginsClients must send the token
Cookie-based token for SPAsWorks with JavaScript frontendsExtra frontend and handler config in Security 6
DisableSimple for header-token APIsDangerous 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 seeWhyFix
GET works but POST returns 403CSRF on and no token sentSend the token, or disable it if the API is header-token only
SPA POSTs fail after upgrading to Security 6Token masking changed how the token is readConfigure the SPA request handler for your version
Attacker's page transfers moneyCSRF disabled while using a session or JWT cookieTurn 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 protection

CORS 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

OptionYou gainYou pay
No CORSNothing to configureBrowser frontends on other origins cannot call the API
Exact allowed originsSafe and explicitList must be maintained
* or reflect any originEverything worksAny 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 seeWhyFix
Browser console: blocked by CORS policy; preflight 401OPTIONS reached the security filters without http.cors()Enable http.cors() so preflights are answered first
Startup or request error with credentialsallowCredentials(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 protection

Security headers and CSP

Step 1Why it exists

Headers tell the browser to block clickjacking, content sniffing and unexpected scripts.

How it is used

Defaults are on. Service B relaxes frame options to sameOrigin only for the dev H2 console.

Step 3Options and trade-offs

OptionYou gainYou pay
Defaults onlyGood baseline, no workNo Content-Security-Policy
Add a strict CSPStrong XSS defenceBreaks inline scripts and some tools (Swagger UI) until tuned
Turn headers offNothing breaksClickjacking and sniffing open

Pick: Keep defaults; add CSP in report-only mode first, then enforce.

Step 4If it fails

What you seeWhyFix
H2 console or an embedded page is blankX-Frame-Options: DENYRelax to sameOrigin only for that path, only in dev
Page scripts stop running after adding CSPInline scripts not allowedMove scripts to files or use nonces; start with report-only

Step 2Hypothesis to test

curl -I on any endpoint shows X-Content-Type-Options: nosniff and X-Frame-Options: DENY.

Step 5Connects to

Hypothesis Book E10–E12.

§8 Exploit protection

HTTPS 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

OptionYou gainYou pay
TLS ends at the proxy, app trusts forwarded headersCommon, simple certificatesApp must be told the original scheme and host
TLS all the way to the appEncrypted inside the network tooCertificates on every service
Service mesh mTLSEncrypted and authenticated between servicesInfrastructure 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 seeWhyFix
OAuth2 login fails with invalid redirect_uriApp builds http:// URLs behind an HTTPS proxyConfigure forwarded headers
Login redirects users to httpSame causeSame fix; add HSTS
Attacker spoofs X-Forwarded-ForApp trusts headers from anyoneTrust 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 JWT

JWT or opaque token

Step 1Why it exists

The API must decide whether to trust the token's own content or ask the auth server each time.

How it is used

This repo issues RS256-signed JWTs from Spring Authorization Server; resource servers verify them locally.

Step 3Options and trade-offs

OptionYou gainYou pay
JWT (self-contained)Verified locally; no network call per requestReadable by anyone; valid until exp
Opaque + introspectionInstant revocation; nothing readable in the tokenAuth server call per request (cache it)
Both (JWT for most, introspection for sensitive APIs)BalanceTwo configurations

Pick: JWT with short expiry for most APIs; introspection where instant revoke matters (payments admin, banking).

Step 4If it fails

What you seeWhyFix
API latency doublesIntrospection without cachingCache results for a short time
Personal data visible in logsPII put inside JWT claimsKeep claims minimal

Step 2Hypothesis to test

Pasting a token into jwt.io shows every claim without any key.

Step 5Connects to

Guide §9.1, §9.5.

§9 JWT

Signing algorithm: HS256, RS256 or ES256

Step 1Why it exists

The signature is what stops anyone from editing the token. Who holds the signing key decides who can mint tokens.

How it is used

RS256 with a key pair: the auth server signs, services verify with the public key from JWKS.

Step 3Options and trade-offs

OptionYou gainYou pay
HS256 (shared secret)Simple, fastEvery service with the secret can forge tokens
RS256 (RSA key pair)Only the auth server can sign; most widely supportedBigger tokens and keys
ES256 (elliptic curve)Small keys and signaturesSlightly less tooling support

Pick: RS256 or ES256 with JWKS. HS256 only inside a single app.

Step 4If it fails

What you seeWhyFix
A compromised service creates admin tokensHS256 secret shared across servicesMove to asymmetric keys
Token with alg: none acceptedHand-written verification codeUse Spring's JwtDecoder, which rejects it

Step 2Hypothesis to test

Changing one character in the payload makes Service A return 401 invalid_token.

Step 5Connects to

Hypothesis Book F3, F4, F14.

§9 JWT

Where 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

OptionYou gainYou pay
Generated in memoryZero setupRestart = every token invalid; each instance has a different key
Keystore fileStable across restartsFile must be protected and distributed
KMS / HSM / VaultKey never leaves secure hardware; auditedCost and setup

Pick: Keystore or KMS in production; publish old and new keys in JWKS during rotation.

Step 4If it fails

What you seeWhyFix
Everyone gets 401 after the auth server restartsDev in-memory key regeneratedExpected in dev; use the keystore elsewhere
About half the requests fail with 401Two auth server instances, each with its own generated keyShare one key (keystore/KMS) across instances
Tokens fail right after rotationOld key removed from JWKS before old tokens expiredKeep 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.

§9 JWT

Revoking tokens

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

OptionYou gainYou pay
Short expiry + refresh tokenMostly stateless; access ends within minutesRefresh store and rotation
Deny-list of jti in RedisInstantA lookup per request
Opaque token + introspectionInstantAuth 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 seeWhyFix
Fired user keeps access for a day24-hour access tokensShorten expiry
Users logged out every 5 minutesShort tokens without refreshAdd 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.

§9 JWT

Where a browser keeps tokens

Step 1Why it exists

Wherever the token sits, an attacker who runs script on the page (XSS) or forges requests (CSRF) will aim for it.

How it is used

Not applicable yet in this repo (no browser frontend).

Step 3Options and trade-offs

OptionYou gainYou pay
localStorageEasyAny XSS steals it
JavaScript memoryHarder to stealLost on refresh; XSS can still use it
HttpOnly cookieScript cannot read itNeeds CSRF protection
BFF (tokens stay on the server)Browser never sees tokensExtra backend component

Pick: BFF for SPAs; HttpOnly cookie + CSRF as a second choice.

Step 4If it fails

What you seeWhyFix
Accounts taken over after an XSS bugTokens in localStorageMove to BFF or HttpOnly cookies
Cross-site forms act as the userToken in a cookie with CSRF disabledEnable CSRF

Step 2Hypothesis to test

In DevTools → Application, a BFF app shows only a session cookie and no tokens.

Step 5Connects to

Guide §9.6.

§9 JWT

Turning claims into authorities

Step 1Why it exists

Spring reads only scopes by default; roles from other identity providers sit in custom claims.

How it is used

This repo uses the default: scope → SCOPE_x.

Step 3Options and trade-offs

OptionYou gainYou pay
Default (scopes only)No codeNo roles
JwtGrantedAuthoritiesConverter with a claim name and prefixSmall configOne claim only
Custom JwtAuthenticationConverterCombine scopes, roles and nested claimsCode to maintain and test

Pick: Default while only scopes matter; a custom converter once you need user roles.

Step 4If it fails

What you seeWhyFix
Every hasRole returns 403 with a valid tokenRoles are in realm_access.roles (Keycloak) and never mappedWrite a converter that adds ROLE_ authorities
Scope checks stop working after adding a converterConverter replaced the scope mapping instead of adding to itCombine both lists

Step 2Hypothesis to test

Print authentication.getAuthorities() in a controller: only SCOPE_ entries appear by default.

Step 5Connects to

Guide §9.3.

§9 JWT

Audience check

Step 1Why it exists

A valid token meant for one client or service should not be accepted everywhere.

How it is used

Not configured in this repo yet: any token from the issuer with the right scope is accepted.

Step 3Options and trade-offs

OptionYou gainYou pay
No audience check (default)Nothing to configureTokens can be replayed to other services
spring.security.oauth2.resourceserver.jwt.audiencesOne lineMust match what the auth server puts in aud
Custom OAuth2TokenValidatorAny rule (several audiences, per path)Code

Pick: Set audiences on every resource server.

Step 4If it fails

What you seeWhyFix
Token for one app works on anotherNo audience checkConfigure audiences
All requests 401 after enabling itExpected value does not match the token's aud (Spring Authorization Server puts the client id there)Decode a real token and use its aud

Step 2Hypothesis to test

Set Notification's audience to payment-service-b; a payment-client token then gets 401 there.

Step 5Connects to

Gap found in this repo; next high task.

§10 OAuth2

Choosing an OAuth2 grant

Step 1Why it exists

How the client gets a token depends on whether a user is present and whether the client can keep a secret.

How it is used

payment-client uses authorization code (Postman); payment-service-b and settlement-job use client credentials.

Step 3Options and trade-offs

OptionYou gainYou pay
Authorization code + PKCEUsers log in at the auth server; secure for web, SPA, mobileRedirect flow
Client credentialsMachine to machine, no userNo user identity in the token
Device codeTVs and CLIsPolling, special UX
Implicit / passwordOld shortcutsDeprecated: token in URL, app sees the password

Pick: Users: authorization code + PKCE. Services and jobs: client credentials.

Step 4If it fails

What you seeWhyFix
Token leaks through browser history or referrerImplicit flow returns the token in the URLSwitch to code + PKCE
Downstream audit shows only a service nameClient credentials used where the user matteredRelay or exchange the user's token

Step 2Hypothesis to test

A client-credentials token has sub = the client id and no user information.

Step 5Connects to

Guide §10.3.

§10 OAuth2

Which 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

OptionYou gainYou pay
Write your own token endpointFull controlYou own every security bug
Spring Authorization ServerJava, customisable, same stackYou run, patch and scale it
KeycloakFeature-rich, admin UI, open sourceSeparate product to operate
SaaS (Okta, Auth0, Entra ID)No servers; MFA and compliance includedCost; vendor lock-in

Pick: Learning and custom needs: Spring Authorization Server. Company production: Keycloak or a SaaS provider.

Step 4If it fails

What you seeWhyFix
Every service fails to get new tokensSingle auth server instance downRun it highly available
Tokens rejected after moving the auth serverissuer 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.

§11 Microservices

Authenticating service-to-service calls

Step 1Why it exists

Inside the network a compromised service must not be able to call everything.

How it is used

Service B calls Notification with its own client-credentials token (notification.send scope).

Step 3Options and trade-offs

OptionYou gainYou pay
Shared API keySimpleCopied everywhere; no scopes; hard to rotate
Client credentialsCentral control, scopes, rotationDepends on the auth server
mTLSStrong identity at network levelCertificate management
Service meshAutomatic mTLS, no codeKubernetes and operations cost

Pick: Client credentials with least-privilege scopes; add mTLS or a mesh for transport.

Step 4If it fails

What you seeWhyFix
Notification fails with 401 for every paymentClient secret rotated in the auth server but not in Service BRotate both together; read secrets from a vault
A compromised service sends emails or refundsScope too broadOne narrow scope per client

Step 2Hypothesis to test

Service B's own token has scope notification.send only; using it on Service A's POST /payments returns 403.

Step 5Connects to

Repo: Service B → Notification.

§11 Microservices

Passing the user to downstream services

Step 1Why it exists

Downstream services often need to know which user the call is for, not just which service made it.

How it is used

Service A forwards the caller's token to Service B (token relay); Service B re-validates it.

Step 3Options and trade-offs

OptionYou gainYou pay
Token relayUser identity end to end; no extra setupSame broad token travels everywhere
Token exchange (RFC 8693)Narrow token per downstream serviceAuth server support and config
Plain header (X-User-Id)TrivialAny caller can fake it

Pick: Relay for short chains; token exchange as the system grows. Never plain headers.

Step 4If it fails

What you seeWhyFix
Service B rejects relayed tokens after adding an audience checkToken's audience is Service A's clientAccept that audience, or exchange the token
Any internal caller impersonates any userIdentity passed in a plain headerPass a signed token

Step 2Hypothesis to test

Service B logs the same sub (nithin) that called Service A.

Step 5Connects to

Guide §11.2.

§11 Microservices

Check at the gateway or in every service

Step 1Why it exists

If only the gateway checks tokens, anything that reaches a service directly is trusted.

How it is used

This repo: zero trust. Service B validates every token even though Service A already did.

Step 3Options and trade-offs

OptionYou gainYou pay
Gateway onlyOne check, fasterOne bypass = full access
Every service (zero trust)Each service protects itselfRepeated validation (cheap with JWT)

Pick: Every service validates; the gateway adds rate limits and coarse checks.

Step 4If it fails

What you seeWhyFix
Internal attacker calls Service B directly with no tokenService B trusted the gatewayMake Service B a resource server

Step 2Hypothesis to test

Calling Service B on port 8081 directly without a token returns 401.

Step 5Connects to

Course Lecture 51 (proxies).

§13 Testing

How to test security rules

Step 1Why it exists

Security rules break silently. Only a test notices that an endpoint became open.

How it is used

This repo has end-to-end PowerShell checks (test-all.ps1); Services A and B have no MockMvc security tests yet.

Step 3Options and trade-offs

OptionYou gainYou pay
@WithMockUser / .with(jwt()) in MockMvcFast; tests authorization rulesSkips real token validation
Real tokens from the auth server (Testcontainers or scripts)Tests the full flowSlower; more setup
Manual Postman checksQuick to exploreNot repeatable; forgotten

Pick: MockMvc tests for every rule (allow and deny), plus a small end-to-end suite with real tokens.

Step 4If it fails

What you seeWhyFix
Tests pass but production is open@WebMvcTest did not load your SecurityConfigImport it, or use @SpringBootTest
POST tests return 403 in a session appNo .with(csrf())Add it

Step 2Hypothesis to test

Tomorrow's task: T1–T5 against Service B.

Step 5Connects to

Guide §13; calendar high task.

§14 Production

Managing secrets

Step 1Why it exists

Client secrets, keystore passwords and mail passwords give an attacker real access.

How it is used

Notification reads Gmail credentials from environment variables; auth server clients use {noop} secrets in code (demo).

Step 3Options and trade-offs

OptionYou gainYou pay
Hard-coded in code or YAMLEasyLeaks with the repo; stays in git history
Environment variablesSimple, out of gitVisible to anyone on the host; no rotation
Vault / cloud secret managerAudited, rotated, access-controlledSetup and a runtime dependency

Pick: Environment variables at minimum; a secret manager for production.

Step 4If it fails

What you seeWhyFix
Secret found in a public GitHub repoCommitted to YAMLRotate it immediately; deleting the file is not enough
Service cannot start after rotationOld secret still configuredRotate with overlap: add new, deploy, remove old

Step 2Hypothesis to test

git log -p | grep -i secret shows every secret ever committed.

Step 5Connects to

Guide §14.

§14 Production

Exposing 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

OptionYou gainYou pay
Health only, publicLoad balancer checks workLittle insight
More endpoints behind an admin role / separate chainUseful operations dataMust protect correctly
Management port on the internal networkNot reachable from outsideNetwork setup

Pick: Public health; everything else on a separate port or chain with admin access.

Step 4If it fails

What you seeWhyFix
Passwords visible at /actuator/env or in a heap dumpEndpoints exposed without authRestrict 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.

§14 Production

When the auth server is down

Step 1Why it exists

Every login and every new service token depends on one system.

How it is used

Resource servers cache the JWKS keys; client-credentials tokens are cached until expiry.

Step 3Options and trade-offs

OptionYou gainYou pay
Single instanceSimpleOutage stops all new logins and service tokens within minutes
Highly available (several instances, shared keys and storage)Survives node failureShared key and session store needed
Managed providerProvider handles uptimeCost; their outage is yours

Pick: At least two instances with a shared signing key, plus monitoring of the token endpoint.

Step 4If it fails

What you seeWhyFix
New logins fail, existing users fine for a few minutesAuth server down; cached tokens still validRestore the auth server; alert on token endpoint errors
Services fail to startStartup code fetches issuer metadata eagerlyRely on lazy issuer-uri decoding; retry

Step 2Hypothesis to test

Stop the auth server mid-test: calls keep working until tokens expire (5 minutes), then all fail.

Step 5Connects to

Hypothesis Book G9.