Securing REST Endpoints with JWT
Securing REST Endpoints with JWT
The previous lessons produced two building blocks: a JwtUtil that mints and verifies tokens, and a JwtAuthenticationFilter that reads a token from every incoming request and loads the corresponding Authentication object into the SecurityContext. This lesson wires everything together by configuring Spring Security to enforce those identities — deciding which endpoints are public, which require a valid JWT, and which demand a specific role.
The SecurityFilterChain Bean
Spring Security 6 replaced the old WebSecurityConfigurerAdapter subclass with a plain @Bean of type SecurityFilterChain. You declare the rules once in a @Configuration class and Spring registers your filter chain alongside its own defaults.
SessionCreationPolicy.STATELESS matters: Without this flag, Spring Security may still create an HttpSession and store the SecurityContext there. That defeats stateless JWT — the server would start accumulating session state, and load-balanced instances would disagree about who is authenticated. STATELESS tells Spring never to touch the session store.
How the Filter Order Works
Spring Security builds its security as a chain of servlet filters. When a request arrives the filters execute in order. addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) ensures your JWT filter runs first — it populates SecurityContextHolder — so that when the built-in authorization filter checks the context later, it already finds a valid Authentication.
If you place the JWT filter after the authorization filter, authenticated requests will be rejected because the context is empty at the time the check happens. Order matters.
Authorisation Rules — Pattern Matching Tips
Spring Security evaluates requestMatchers rules in the order they are declared, stopping at the first match. Keep this in mind:
- Declare public paths first, then progressively more restrictive rules, and end with
anyRequest().authenticated()as the catch-all. - Use
HttpMethodvariants when the same URL path should be publicly readable but protected for mutations. For example,GET /api/products/**can be open, whilePOST /api/products/**should require authentication. hasRole("ADMIN")is shorthand forhasAuthority("ROLE_ADMIN"). Spring prepends theROLE_prefix automatically when you usehasRole.
Method-Level Security with @PreAuthorize
@EnableMethodSecurity (added at class level above) unlocks Spring Expression Language (SpEL) annotations directly on controller or service methods. This is a powerful complement to URL rules — you can express business-specific conditions that URL patterns alone cannot capture.
The @orderSecurity.isOwner(#id, authentication) expression delegates to a Spring bean named orderSecurity — a simple @Component with an isOwner(Long id, Authentication auth) method. This keeps business-logic authorization out of generic URL rules.
Returning Proper HTTP Errors for Unauthenticated and Forbidden Requests
By default, Spring Security redirects to a login page on authentication failure — useless for a REST API. You need to configure custom entry points that return JSON with the correct HTTP status codes.
CORS Configuration for Frontend Clients
When your React or Angular frontend runs on a different origin (e.g., http://localhost:3000) than the API (e.g., http://localhost:8080), browsers block the request unless the server sends the correct CORS headers. Configure CORS inside the Spring Security chain so the headers are added even for requests rejected before reaching your controllers.
allowedOrigins("*") with allowCredentials(true). Browsers forbid that combination for good reason — it would allow any origin to make credentialed cross-origin requests to your API. If you need credentials, enumerate the allowed origins explicitly.
Putting It All Together — A Request Walk-Through
Trace a POST /api/orders request with a valid JWT Bearer token through the full chain:
- The request enters the servlet container and starts traversing the
SecurityFilterChain. - JwtAuthenticationFilter extracts the
Authorization: Bearer <token>header, validates the JWT, callsUserDetailsService.loadUserByUsername(), builds aUsernamePasswordAuthenticationToken, and stores it inSecurityContextHolder. - The built-in AuthorizationFilter evaluates
anyRequest().authenticated()— the context is populated, so the request passes. - The
DispatcherServletroutes to OrderController.createOrder(), where@PreAuthorize("isAuthenticated()")is checked again at method level — it passes. - The controller logic runs; a 201 response is returned.
If the token is missing or expired, step 2 sets no authentication, step 3 triggers the authenticationEntryPoint, and a 401 JSON response is returned immediately — the controller is never reached.
Summary
A production-grade JWT security configuration requires four elements working together: a SecurityFilterChain bean that declares URL-level rules and disables sessions; the JWT filter inserted before the username/password filter; method-level @PreAuthorize annotations for fine-grained business rules; and custom entry points that return machine-readable JSON errors. Add CORS configuration here — in the security layer — so it covers all paths including rejected requests. In the next lesson you will extend this foundation to include roles and authorities carried inside the JWT claims themselves.