Why Spring Security Returns 401 or 403: One Request Through the Filter Chain

October 1, 2026 · How It Actually Works: Real Systems as State Machines (part 11)

▶ Watch on YouTube & subscribe to The Stack Underflow

You add Spring Security, and suddenly requests answer 401, or 403, or redirect to a login page. None of those answers is random: each one comes from one exact step in the filter chain. This page follows one request through Spring Security, from Tomcat to your controller and back, the same way the video’s state machine does.

The one-line version: 401 means “who are you? Go to the desk.” 403 means “we know who you are, but your badge doesn’t open this door.”

Last verified against the Spring Security 7.1.1 and Spring Boot 4.1.1 reference documentation: 1 October 2026. Scope: servlet stack (Tomcat + Spring MVC). WebFlux is out of scope, and OAuth2 login has its own page.

The example app and the memory pegs

The video models an app with two filter chains:

  • Chain 1 (@Order(1)), for /api/**: JWT bearer tokens (oauth2ResourceServer), stateless, no session.
  • Chain 2 (@Order(2)), for everything else: a login form and an HTTP session.

The video teaches the words with one analogy, an office building’s security desk. These are memory aids, not definitions:

WordMemory peg (analogy)What it actually is
Filter chainThe security checkpoints, always in the same orderAn ordered list of servlet filters selected for this request
AuthenticationChecking your ID at the deskTurning credentials into a trusted Authentication
SecurityContextThe visitor badge you wearHolds the current Authentication for this request
SessionThe visitor list at the deskWhere a form login’s SecurityContext is saved between requests
AuthorizationWhich doors your badge opensDeciding whether this Authentication may do this request
JWTA signed badge you carry with youA signed token the caller presents on every request
CSRF tokenThe desk’s stamp on the formA per-session value proving the form came from your app

Startup: your chains become one servlet filter

Spring Security is not a separate server. At startup your configuration becomes ordinary Spring beans:

  • If you define no SecurityFilterChain bean, Spring Boot adds a default one: every request must be authenticated, with form login or HTTP Basic chosen by the request’s Accept header. Unless you define your own users, Boot also creates one named user and prints its generated password at WARN level, for development only.
  • Defining your own SecurityFilterChain bean switches Boot’s default chain off.
  • With several chains, @Order decides which is tried first, and a catch-all chain must come last, or startup fails.
  • All chains go into one FilterChainProxy bean named springSecurityFilterChain. Tomcat knows nothing about Spring beans, so Boot registers a DelegatingFilterProxy as a plain servlet filter; it looks up the real bean lazily, on first use.

One request: only the first matching chain runs

Tomcat calls DelegatingFilterProxy, which hands the request to FilterChainProxy. Its HTTP firewall first rejects suspicious URLs (400 Bad Request by default). Then it tries the chains in order and uses only the first one that matches: /api/orders goes to chain 1 even though chain 2 would match it too. At the end, FilterChainProxy clears the SecurityContextHolder so the badge never leaks to the next request on that thread.

The filters, in order

Inside the chosen chain, the filters run in a fixed order, and any of them can answer by itself:

StepFilterWhat it does here
1SecurityContextHolderFilterLoads the SecurityContext (for example from the session) into the thread-local SecurityContextHolder. It only loads; it never saves
2CorsFilter (when CORS is on)Answers browser preflight requests itself; rejects a cross-origin request from a disallowed origin with 403
3CsrfFilterChecks POST, PUT, PATCH, DELETE for the CSRF token; a missing or wrong token gets 403
4Authentication filtersUsernamePasswordAuthenticationFilter (form chain) or BearerTokenAuthenticationFilter (API chain), then AnonymousAuthenticationFilter
5ExceptionTranslationFilterWraps everything after it and turns security exceptions into 401, a login redirect, or 403
6AuthorizationFilterLast by default: checks your requestMatchers rules

CORS. CORS is on when you call http.cors(...) or define a UrlBasedCorsConfigurationSource bean. A preflight carries no cookies or tokens, so CorsFilter sits before CSRF and authentication and answers it before authentication could reject it.

CSRF. GET, HEAD, TRACE and OPTIONS pass without a token. Other methods must send the token in a form field (_csrf) or a header (X-CSRF-TOKEN), and CsrfFilter checks it against the one stored, by default, in the session. On a chain with the resource server, CsrfFilter ignores requests that carry a bearer token: a browser never adds that header by itself, so a forged form cannot use it. A missing or invalid token becomes an AccessDeniedException passed to the AccessDeniedHandler: 403, even for a logged-in user.

Authentication: who is this caller?

Each authentication filter looks for one kind of credential; a filter that finds none passes the request on.

  • A bearer token is wrapped in a BearerTokenAuthenticationToken (not yet trusted) and passed to the AuthenticationManager.
  • A form posted to /login becomes a UsernamePasswordAuthenticationToken.
  • With no credentials at all, AnonymousAuthenticationFilter puts in an anonymous token, anonymousUser, with the authority ROLE_ANONYMOUS. Anonymous is not the same as authenticated.

The AuthenticationManager is usually a ProviderManager, which asks its providers in turn. DaoAuthenticationProvider loads the user from your UserDetailsService and checks the password with the PasswordEncoder (by default a DelegatingPasswordEncoder, where a prefix like {bcrypt} picks the algorithm). An unknown user fails exactly like a wrong password, with BadCredentialsException, so nobody can probe which usernames exist.

Where the badge is kept differs by chain. A bearer token’s SecurityContext is kept only as a request attribute: the next request must bring its token again. After a form login, the SecurityContext is also saved in the HTTP session, the session ID is changed (against session fixation), and the browser is redirected to the page it first asked for.

When authentication fails: for a bearer token, 401 with a WWW-Authenticate: Bearer header saying invalid token; for a login form, a redirect back to /login?error.

JWT bearer tokens: checked on every request

With spring.security.oauth2.resourceserver.jwt.issuer-uri set:

  1. Discovery is lazy. The decoder discovers the issuer’s JWK Set URL on the first request with a token, not at startup, and picks up new keys when the issuer rotates them.
  2. Signature must verify with one of the issuer’s public keys.
  3. exp and nbf allow 60 seconds of clock skew by default. A token with no exp at all passes by default (in the 7.1.1 source, JwtTimestampValidator has allowEmptyExpiryClaim = true).
  4. iss must equal the configured issuer.
  5. aud is checked only if you configure audiences, so configure them:
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/issuer
          audiences: https://orders-api.example.com

Each scope in the scope or scp claim becomes an authority with the prefix SCOPE_, so orders.read becomes SCOPE_orders.read. The result is a JwtAuthenticationToken.

Where 401 and 403 really come from

AuthorizationFilter compares the request with your rules in the order you wrote them and applies only the first match. hasRole('ADMIN') means the authority ROLE_ADMIN; JWT scopes are SCOPE_ authorities, not roles, so use hasAuthority('SCOPE_orders.read') for them.

If the rule fails, AuthorizationFilter does not pick a status code. It throws an AccessDeniedException, and ExceptionTranslationFilter decides:

Who was deniedWhat happensResult
Anonymous, API chainContext cleared, AuthenticationEntryPoint called401 with WWW-Authenticate: Bearer
Anonymous, form chainRequest saved in the RequestCache, then the entry pointRedirect to the login page
Authenticated, form chainAccessDeniedHandler403 Forbidden
Authenticated, API chainAccessDeniedHandler403, plus a WWW-Authenticate header saying insufficient scope

The exceptions to “AuthorizationFilter decides” are earlier filters that answer directly: CSRF failures (403), disallowed CORS origins (403) and invalid bearer tokens (401).

Spring MVC and method security

Past the last filter, DispatcherServlet calls your controller, still inside ExceptionTranslationFilter’s try block. Method security is off by default: with @EnableMethodSecurity, a bean with @PreAuthorize is wrapped in an AOP proxy that evaluates the expression before the method runs. If it is false, the method never runs; the AccessDeniedException travels back up to ExceptionTranslationFilter (403 for an authenticated user), unless a catch-all exception handler of yours swallows it first.

Pause & Prove

1. A logged-in user submits a form with POST but without the CSRF token. What status do they get, and which filter says no?

403 Forbidden, from CsrfFilter. Being logged in doesn’t help: CsrfFilter runs before authorization and passes an AccessDeniedException to its AccessDeniedHandler. Two near-misses: a GET would have passed without a token, and so would a bearer-token call on the API chain.

2. A browser sends a CORS preflight (OPTIONS) to a Spring Security app with CORS on. Who answers it?

  • AuthorizationFilter. It runs last; the preflight never gets that far.
  • CorsFilter. ✓ It sits before CSRF and authentication and answers the preflight right there.
  • Your controller. The preflight is handled in the filter chain, before Spring MVC.
  • Nobody: it gets 401. Tempting, because a preflight carries no cookies or tokens. That is exactly why CORS is processed first: otherwise authentication would reject it.

Peg drill

Say the peg for each, then check: SecurityFilterChain (the row of security checkpoints) · SecurityContext (the visitor badge you wear) · session (the visitor list at the desk) · JWT (a signed badge you carry) · 401 (who are you? Go to the desk) · 403 (we know who you are, but your badge doesn’t open this door).

Five things to remember

  1. Only the first matching chain runs, so order matters.
  2. CSRF checks unsafe methods like POST, not GET, and not bearer-token API calls.
  3. Denied while anonymous gives 401 or a login redirect; denied while logged in gives 403.
  4. hasRole('ADMIN') means ROLE_ADMIN; JWT scopes become SCOPE_ authorities.
  5. With an issuer URI, a JWT’s signature, expiry and issuer are checked; the audience only if you configure it.

Before / after this video

Sources

Checked on 1 October 2026 (docs pages identify as Spring Security 7.1.1):

Change notes

  • 1 Oct 2026: first published.

Not affiliated with or endorsed by VMware Broadcom or the Spring team. Spring is a trademark of Broadcom Inc. and/or its subsidiaries. Found a mistake? Tell us in the video’s comments and we’ll correct this page.

Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.

Subscribe on YouTube →

Prefer email? Get the free newsletter: one failure, traced step by step, about once a week.