JWT, OAuth2 & Securing APIs

A JWT Authentication Filter

18 min Lesson 4 of 13

A JWT Authentication Filter

In a stateless Spring Security application every incoming HTTP request must carry proof of identity — there is no server-side session to look up. The component responsible for examining that proof on each request is a servlet filter. In this lesson you will build a production-quality JwtAuthenticationFilter that sits early in Spring Security's filter chain, extracts the token from the Authorization header, validates it, and populates Spring's SecurityContext so the rest of the framework behaves as if a session-authenticated user were present.

Where the Filter Lives in the Chain

Spring Security processes every request through an ordered chain of filters before it ever reaches a controller. By extending OncePerRequestFilter — a Spring convenience class that guarantees exactly one execution per request, even across forward or include dispatches — your filter integrates cleanly with that chain. You then tell Spring Security to run it before UsernamePasswordAuthenticationFilter so credentials are resolved before any subsequent filter tries to redirect to a login page.

Why OncePerRequestFilter and not a plain Filter? A raw javax.servlet.Filter can be invoked multiple times in a single HTTP exchange if the request is forwarded internally (e.g. error dispatches). OncePerRequestFilter uses a request-scoped flag to prevent that, which matters for security: you do not want authentication logic running twice with potentially inconsistent state.

Extracting the Token from the Request

The OAuth 2.0 Bearer Token specification (RFC 6750) defines how a token should be transmitted: in an HTTP header named Authorization with the value Bearer <token>. Your filter must read that header and strip the prefix.

private String extractToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { return header.substring(7); // strip "Bearer " } return null; }

Returning null here is intentional: many requests (static assets, public endpoints) will not carry a token, and the filter should simply pass them through without error. Authentication is attempted; it is the security configuration that decides whether a missing token is a problem.

The Full Filter Implementation

Here is the complete filter. Read it top-to-bottom; every design decision is explained below.

package com.example.security; import com.example.service.JwtService; import com.example.service.UserDetailsServiceImpl; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsServiceImpl userDetailsService; public JwtAuthenticationFilter(JwtService jwtService, UserDetailsServiceImpl userDetailsService) { this.jwtService = jwtService; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { // 1. Extract the raw token string String token = extractToken(request); // 2. If no token is present, skip — let the chain decide if (token == null) { filterChain.doFilter(request, response); return; } // 3. Avoid re-authenticating an already-authenticated request if (SecurityContextHolder.getContext().getAuthentication() != null) { filterChain.doFilter(request, response); return; } // 4. Extract the subject (username / user ID) from the token String username = jwtService.extractUsername(token); if (username != null) { // 5. Load the full UserDetails from the database UserDetails userDetails = userDetailsService.loadUserByUsername(username); // 6. Validate the token against the UserDetails if (jwtService.isTokenValid(token, userDetails)) { // 7. Build an Authentication object and populate the SecurityContext UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, // credentials — null for JWT userDetails.getAuthorities() ); authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); SecurityContextHolder.getContext().setAuthentication(authToken); } } // 8. Always continue the chain filterChain.doFilter(request, response); } private String extractToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { return header.substring(7); } return null; } }

Step-by-Step Explanation

Step 1–2 — Extract or pass through. If no Authorization: Bearer … header is present the filter does nothing and calls filterChain.doFilter(). Downstream security rules (in SecurityFilterChain) will enforce authentication where needed.

Step 3 — Idempotency check. If the SecurityContext already holds an authentication object — for example because another filter ran first, or the same thread handled an earlier request — there is nothing to do. Skipping this check would overwrite a valid authentication unnecessarily.

Step 4 — Extract the subject. jwtService.extractUsername() parses the JWT and returns the sub claim. This operation does not yet prove the token is valid; it only reads it.

Step 5 — Load UserDetails. The database is the authority on whether the user still exists and is active. A token for a deleted or locked account must be rejected, and the UserDetails loaded here carries that information.

Step 6 — Validate. jwtService.isTokenValid() checks the signature, the expiry, and (optionally) that the username in the token matches the loaded UserDetails. Only when all checks pass does authentication proceed.

Step 7 — Populate the SecurityContext. UsernamePasswordAuthenticationToken is constructed with three arguments: principal (UserDetails), credentials (null — there is no password to carry at this point), and authorities. Passing the authorities collection is what tells Spring Security the user is authenticated; the no-arg constructor creates an unauthenticated token.

Always set WebAuthenticationDetailsSource. Attaching request details (IP address, session ID) to the authentication object enables audit logging and is used by some security event listeners. It costs nothing and provides valuable context.

Step 8 — Always continue. The filter never short-circuits the chain by sending a 401 directly (except by omission — if validation fails, no authentication is set, and Spring Security's AuthenticationEntryPoint handles the 401 response further down). This keeps error-handling logic in one place.

Registering the Filter in the Security Configuration

In Spring Security 6 you register the filter inside your SecurityFilterChain bean using addFilterBefore:

@Configuration @EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter) { this.jwtAuthFilter = jwtAuthFilter; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) // no CSRF for stateless APIs .sessionManagement(sm -> sm .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }
Disable CSRF for stateless APIs — but understand why. CSRF attacks exploit the browser's automatic cookie-sending behaviour. Because a JWT is sent in the Authorization header (not a cookie), the browser will never send it cross-site automatically, so CSRF is not a threat here. If you ever switch to storing JWTs in cookies, you must re-enable CSRF protection.

Security Implications and Distributed-Systems Trade-offs

The database lookup in step 5 is a deliberate trade-off. Pure stateless JWT validation would skip it, but then a token for a deleted or disabled user would remain valid until expiry. Loading UserDetails on every request catches that case at the cost of one extra database query per call. Mitigate the cost with a short-lived in-memory or Redis cache keyed on the username.

In a microservices environment each service typically has its own instance of this filter. The JWT is validated locally (signature verification is CPU-only; no network hop), while the UserDetails lookup may be replaced by reading claims directly from the token — roles and permissions are often embedded as claims so services need not reach out to a user service on every call. Lesson 6 covers that pattern in detail.

Summary

Your JwtAuthenticationFilter is the bridge between a raw HTTP request and Spring Security's authentication model. It extracts the token, validates it through JwtService, loads the user from the database, and populates the SecurityContext — all in a single, idempotent, side-effect-free pass. Register it before UsernamePasswordAuthenticationFilter with STATELESS session policy and Spring Security handles the rest: authorisation rules, 401 responses, and method-level security all work without any further changes.