JWT, OAuth2 & Securing APIs

Securing REST Endpoints with JWT

18 min Lesson 5 of 13

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.

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @EnableMethodSecurity // unlocks @PreAuthorize on controller methods public class SecurityConfig { private final JwtAuthenticationFilter jwtFilter; public SecurityConfig(JwtAuthenticationFilter jwtFilter) { this.jwtFilter = jwtFilter; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 1. Disable CSRF — not needed for stateless JWT APIs .csrf(csrf -> csrf.disable()) // 2. Stateless session — Spring must NEVER create an HttpSession .sessionManagement(sm -> sm .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 3. Authorisation rules — most specific patterns first .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) // 4. Replace the default form-login and basic auth .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) // 5. Insert our JWT filter BEFORE the standard username/password filter .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
Why 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 HttpMethod variants when the same URL path should be publicly readable but protected for mutations. For example, GET /api/products/** can be open, while POST /api/products/** should require authentication.
  • hasRole("ADMIN") is shorthand for hasAuthority("ROLE_ADMIN"). Spring prepends the ROLE_ prefix automatically when you use hasRole.
// Fine-grained method example — require authenticated for POST but not GET .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/products/**").authenticated() .requestMatchers(HttpMethod.PUT, "/api/products/**").hasRole("ADMIN") .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN") .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated())

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.

import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/orders") public class OrderController { // Any authenticated user can list their own orders @GetMapping @PreAuthorize("isAuthenticated()") public List<Order> myOrders(Authentication auth) { return orderService.findByUsername(auth.getName()); } // Only admins can view all orders @GetMapping("/all") @PreAuthorize("hasRole('ADMIN')") public List<Order> allOrders() { return orderService.findAll(); } // Only the order owner or an admin may cancel @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#id, authentication)") public void cancel(@PathVariable Long id) { orderService.cancel(id); } }

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.

// In SecurityConfig.filterChain(), add inside http.exceptionHandling(...) .exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 response.setContentType("application/json"); response.getWriter().write( "{\"error\":\"Unauthorized\",\"message\":\"" + authException.getMessage() + "\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 response.setContentType("application/json"); response.getWriter().write( "{\"error\":\"Forbidden\",\"message\":\"Insufficient privileges\"}"); }))
401 vs 403 — get these right. HTTP 401 (Unauthorized) means the request carries no valid identity — the client should authenticate and retry. HTTP 403 (Forbidden) means the identity is recognised but lacks permission. Returning 403 for unauthenticated requests leaks the fact that the resource exists; many security teams prefer 404 in that case.

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.

import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; // Inside SecurityConfig: @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); cfg.setAllowedOrigins(List.of("http://localhost:3000", "https://myapp.com")); cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS")); cfg.setAllowedHeaders(List.of("Authorization","Content-Type")); cfg.setAllowCredentials(false); // true only if you use cookies (not JWT Bearer) cfg.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", cfg); return source; } // Then in filterChain(): http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
Never use 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:

  1. The request enters the servlet container and starts traversing the SecurityFilterChain.
  2. JwtAuthenticationFilter extracts the Authorization: Bearer <token> header, validates the JWT, calls UserDetailsService.loadUserByUsername(), builds a UsernamePasswordAuthenticationToken, and stores it in SecurityContextHolder.
  3. The built-in AuthorizationFilter evaluates anyRequest().authenticated() — the context is populated, so the request passes.
  4. The DispatcherServlet routes to OrderController.createOrder(), where @PreAuthorize("isAuthenticated()") is checked again at method level — it passes.
  5. 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.