10 — The filter chain, in execution order
Read this first: this lesson opens Part 3 by reading the course's one whole-file security
decode — SecurityConfiguration.java, about 155 lines that decide what every request survives
before a controller method runs — plus the audit interceptor that rides behind it. Code is quoted
inline so you can read this without the repository open. By the end you can list the filters a
request crosses before @PreAuthorize, in execution order, and justify every entry in
PUBLIC_ENDPOINTS.
Time: about 35 minutes. Assumes lesson 09.
Five filters, one bean
Parts 1 and 2 walked from the controller inward. Part 3 walks the other way: everything that
happens before the controller. All of it is declared in one bean — the class also carries
@EnableMethodSecurity, which is what makes @PreAuthorize on controller methods work at all:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtTokenManager, userDetailsService);
AltchaVerificationFilter altchaVerificationFilter = new AltchaVerificationFilter(altchaService);
LoginRateLimitFilter loginRateLimitFilter = new LoginRateLimitFilter(authMetrics);
return http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(handler -> handler.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.headers(headers -> headers
// … six header writers, quoted in "The headers" below
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(PUBLIC_ENDPOINTS).permitAll()
.anyRequest().authenticated())
.authenticationProvider(authenticationProvider())
// Effective order: TenantContextFilter -> LoginRateLimitFilter ->
// AltchaVerificationFilter -> JwtAuthenticationFilter. Rate limit before the
// proof-of-work verification (cheapest check first), and the tenant scope outermost
// of all, because resolving who is calling is itself database work -- and because
// whatever happens inside, the scope has to be closed on the way out. A user
// holding a temporary password is then stopped just after authentication, since the
// principal has to exist before the flag on it can be read.
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// Straight after authentication: it reads the flag off the principal, so the
// principal has to exist, and a user carrying one must reach nothing beyond this.
// Registered after the line above because a filter can only be positioned relative
// to one the builder already knows about.
.addFilterAfter(new PasswordChangeRequiredFilter(), JwtAuthenticationFilter.class)
.addFilterBefore(altchaVerificationFilter, JwtAuthenticationFilter.class)
.addFilterBefore(loginRateLimitFilter, AltchaVerificationFilter.class)
.addFilterBefore(new TenantContextFilter(), LoginRateLimitFilter.class)
.build();
}
Registration order is not execution order
Read the five addFilter* lines top to bottom: JWT, password-change, ALTCHA, rate limit, tenant.
Now read the first comment's Effective order: line — it is almost the exact reverse. That is not
sloppiness; it is the API. Each addFilterBefore/addFilterAfter call positions a filter
relative to another filter, and the second comment states the constraint outright:
Registered after the line above because a filter can only be positioned relative to one the builder already knows about.
The builder starts out knowing only Spring's standard filter positions —
UsernamePasswordAuthenticationFilter among them — so the first custom filter anchors to a
standard one, and every later line anchors to a custom filter that an earlier line already
registered. JWT anchors to Spring; ALTCHA anchors to JWT; rate limit anchors to ALTCHA; tenant
anchors to rate limit. Registration order is dependency order, built from the inside of the chain
outward. What actually runs is:
request
│
▼
TenantContextFilter open the tenant scope (and close it on the way out)
│
LoginRateLimitFilter bucket empty? 429, and nothing below ever runs
│
AltchaVerificationFilter required proof-of-work missing or replayed? 428
│
JwtAuthenticationFilter validate the token, load the user, fill the security context
│
PasswordChangeRequiredFilter temporary password? stop right here
│
(UsernamePasswordAuthenticationFilter) Spring's anchor position — see below
│
authorization rules → @PreAuthorize → controller
The parenthesised entry earns its parentheses: with no form login configured, Spring never adds
UsernamePasswordAuthenticationFilter to this chain at all. The class name is used purely as a
coordinate — you can anchor to a position whose filter will never exist, which is the cleanest
proof that these calls describe positions, not a top-to-bottom pipeline.
Predict: swap the last two registration lines, so TenantContextFilter is registered before
the line that registers LoginRateLimitFilter. What actually changes — does the tenant filter
run somewhere else now? Write your answer down before reading on.
Resolved: nothing runs anywhere, because the application refuses to start. The tenant line's
anchor is LoginRateLimitFilter.class, and after the swap the builder has never heard of it —
building the chain throws, and the context fails on boot. That is the quiet payoff of this design:
registration order cannot drift out of sync with execution order by accident, because getting the
dependency order wrong is a startup failure, not a subtle mis-ordering you discover in production.
Why this order and no other
The Effective order comment packs one justification per position. Unpacked:
- Rate limit before proof-of-work — "cheapest check first." Turning away an abusive login attempt should cost a token-bucket lookup, not an HMAC verification of an ALTCHA payload. The cheap gate shields the expensive one.
- Tenant scope outermost — two reasons, and the comment gives both. First, "resolving who is
calling is itself database work":
JwtAuthenticationFilterreloads the user from the database on every request, and that query already has to run inside a tenant scope (lesson 13 shows why the database insists). Second, "whatever happens inside, the scope has to be closed on the way out" — the outermost filter is the last to regain control, which makes it the only correct place for afinally. - The temporary-password stop sits just after authentication — "the principal has to exist before the flag on it can be read." A user carrying a temporary password can authenticate, but "must reach nothing beyond this" until the password is changed.
PUBLIC_ENDPOINTS, entry by entry
Everything not on this list requires authentication. The list carries its own justifications:
private static final String[] PUBLIC_ENDPOINTS = {
"/api/auth/login",
// The refresh token presented in the body IS the credential for these two -- there's no
// access token to check (it may already be expired), so they can't require prior auth.
"/api/auth/refresh",
"/api/auth/logout",
"/api/public/**",
"/api/portal/auth/**",
// Payment providers cannot present a JWT. These are authenticated by per-provider
// webhook signature verification instead -- see PolarWebhookVerifier.
"/api/webhooks/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/ws/**",
// Listed individually (never /actuator/**) so no other actuator endpoint can leak.
// Neither is routed by the frontend nginx, and production client stacks publish no
// backend host ports -- reachable only inside the compose network.
"/actuator/health",
"/actuator/prometheus"
};
Walk it. /api/auth/login is where you get your first token, so it cannot demand one.
/api/auth/refresh and /api/auth/logout look wrong at first — logout, public? — until you read
the comment: the refresh token in the body is the credential, and the access token may already
be expired, which is the entire reason you are refreshing. /api/public/** holds the endpoints
that exist precisely for unauthenticated visitors (self-serve signup lives here), and
/api/portal/auth/** is the employee portal's own login and registration. Webhooks get the
second comment: a payment provider will never hold one of your JWTs, so those requests are
authenticated by signature verification inside the handler instead. The Swagger and OpenAPI paths
serve the API's own documentation UI. /ws/** is the WebSocket handshake — a browser's WebSocket
API cannot attach an Authorization header, so the socket is authenticated after the upgrade,
not by this chain.
The actuator pair deserves the longest look. It is health and prometheus by name — never
/actuator/** — which is the security half of the actuator two-file rule from
lesson 02: exposure is configured in one file, permission in this
one, and because neither side uses a wildcard, adding a new actuator endpoint requires touching
both files deliberately. A mismatch fails closed.
The headers
Every response leaves wearing six headers configured in the trimmed block above: content-type
sniffing off, X-Frame-Options: DENY, a year-long HSTS with includeSubDomains and preload, a
strict-origin-when-cross-origin referrer policy, a permissions policy that switches off
geolocation, camera, microphone and payment, and a Content-Security-Policy. The CSP's comment is
worth quoting because it is honest about how little the header does for a JSON API:
// 'unsafe-inline' script/style is required for Swagger UI (permitAll'd below,
// see PUBLIC_ENDPOINTS); pure JSON API responses ignore CSP entirely, so this
// only actually constrains the one HTML page this backend serves.
One comment lesson 11 will cash in
The DaoAuthenticationProvider bean carries a single wiring line whose consequences fill half of
the next lesson:
// Enables transparent hash upgrades on successful login (bcrypt -> argon2id); the
// provider consults passwordEncoder.upgradeEncoding() and calls
// UserDetailsServiceImpl.updatePassword with the re-encoded password.
provider.setUserDetailsPasswordService(userDetailsService);
Hold the thought: legacy password hashes upgrade themselves, one successful login at a time. Lesson 11 walks the whole mechanism.
The auditor is not a filter
One more interception layer runs after the chain and the controller both — and it is a Spring MVC
HandlerInterceptor, not AOP. Grep the backend for @Aspect and you get zero matches; nothing
in this tree is aspect-woven. AuditInterceptor is registered in WebMvcConfig:
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(auditInterceptor)
.addPathPatterns("/api/**")
// Login is handled manually in AuthController
.excludePathPatterns("/api/auth/login");
}
Login is excluded not because it goes unaudited but because the auth code logs its own successes,
failures and lockouts with far more detail than a URL. The interceptor implements
afterCompletion, so it observes the finished response — status code and any thrown exception
included — and it ignores reads entirely:
private static final Set<String> MUTATION_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
Then comes the status filter. Successful mutations are logged, and among failures exactly one status is kept.
Predict: the interceptor audit-logs a 403 but not a 401. Both are denials — why would one be worth a row in the audit table and the other not? Write your answer down.
The comment resolves it:
// 401s have no authenticated principal to attribute the denial to -- nothing to log there.
// 403s do: the caller authenticated fine but @PreAuthorize/access rules rejected them, which
// is exactly the kind of denied-request signal audit trails are supposed to capture.
An audit row needs a who. A 401 never got past JwtAuthenticationFilter, so there is no
principal to attribute — a row saying "somebody was rejected" is noise. A 403 is the interesting
case: a known, authenticated user asked for something their permissions deny, and the row is
written with a "DENIED " prefix on the action. Finally, the whole body sits in a try/catch
that ends:
} catch (Exception e) {
log.warn("AuditInterceptor failed to record action: {}", e.getMessage());
}
Warn, never rethrow. The request already completed; failing it retroactively because the record of it could not be written would let an audit-table hiccup break the API. Auditing must never break the thing it describes.
The doc this lesson extends
../security/request-pipeline.md draws this same pipeline as
four layers — CORS, JwtAuthenticationFilter, URL rules, @PreAuthorize — and never mentions
TenantContextFilter or PasswordChangeRequiredFilter, because it predates the multi-tenancy
retrofit. It is not wrong about the four layers it names, and its JWT internals section goes
deeper than this lesson does; read this lesson as additive, not corrective. The full security doc
index is ../security/README.md. And the chain has a mirror on the other
side of the wire: the same permission strings @PreAuthorize checks also decide what the UI
draws — that is ../frontend-101/03-who-can-see-what.md.
Where this shows up in MotorPH
- SecurityConfiguration.java — the file this lesson decodes
- AuditInterceptor.java and WebMvcConfig.java — the auditor and its registration
- JwtAuthenticationFilter.java and PasswordChangeRequiredFilter.java — opened in lesson 11
- TenantContextFilter.java — opened in lesson 12
- ../security/request-pipeline.md — the pre-tenancy four-layer walkthrough this lesson extends
Recap
- Registration order is dependency order, not execution order. A filter can only be positioned relative to one the builder already knows, so the chain is registered from the inside out — and a wrong order is a startup failure, never a silent mis-ordering.
- Cheapest check first, scope outermost. Rate limit before proof-of-work before token validation, and the tenant scope wraps everything because authentication is itself database work and the scope must close on the way out.
- Every
PUBLIC_ENDPOINTSentry carries its justification. Refresh and logout carry their credential in the body, webhooks are signature-verified, and actuator paths are named individually so no other endpoint can leak. - The auditor is a
HandlerInterceptorinafterCompletion, not an aspect — mutations and 403s only, because an audit row needs a principal to attribute the action to. - Auditing never breaks the request it describes. The interceptor warns on failure instead of throwing.