Skip to main content

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": JwtAuthenticationFilter reloads 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 a finally.
  • 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​

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_ENDPOINTS entry 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 HandlerInterceptor in afterCompletion, 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.

Next: 11 — The login path, hardened five ways.