Back to Spring Security: JWT, OAuth2, CSRF/CORS
Q1Question 1 of 3

How does Spring Security work with JWT?

Key Concepts
  • Spring Security uses a servlet filter chain that runs BEFORE DispatcherServlet, which is why @ControllerAdvice cannot catch 401/403
  • A custom JwtAuthenticationFilter extracts the Bearer token, validates the signature and expiry, and sets the SecurityContext
  • SecurityFilterChain bean configures which endpoints are public, which need authentication, and which need specific roles
  • @PreAuthorize uses SpEL expressions for fine-grained method-level authorization via AOP
  • CSRF is disabled for stateless JWT APIs because there is no session to forge
  • WebClient is preferred over RestTemplate for inter-service calls with timeouts, retry, and token propagation
  • Custom AuthenticationEntryPoint (401) and AccessDeniedHandler (403) return structured JSON error responses

Definition

Spring Security uses a servlet filter chain that runs before DispatcherServlet to authenticate every request via JWT token validation and authorize access using @PreAuthorize with SpEL expressions.
  • SecurityFilterChain – an ordered list of servlet filters registered as a single FilterChainProxy that intercepts every request before it reaches the DispatcherServlet.
  • JwtAuthenticationFilter – a custom filter that extracts the JWT from the Authorization: Bearer header, validates the signature and expiry, builds an Authentication object, and stores it in the SecurityContext.
  • SecurityContextHolder – a thread-local holder that stores the current user's Authentication for the lifetime of the request.
  • @PreAuthorize – a method-level annotation that evaluates a SpEL expression against the current Authentication before allowing the method to execute.
  • WebClient – the modern, non-blocking HTTP client preferred over RestTemplate for inter-service calls with timeout, retry, and automatic token propagation.
  • Content negotiation – the mechanism that uses Accept and Content-Type headers to determine the response format (JSON or XML).

Spring Security is the security checkpoint at the entrance of a National Bank of India branch. The JWT token is your ID card. The guard (JwtAuthenticationFilter) checks your ID before you enter the branch. If your ID is expired or forged, you are turned away (401 Unauthorized). Once inside, the vault door (PreAuthorize) only opens for managers with ROLE_ADMIN; a regular employee (ROLE_USER) gets turned away (403 Forbidden). WebClient is the secure phone line the teller uses to call other branches, automatically presenting credentials.

Understanding the Concepts

How does the Spring Security filter chain work?

Spring Security is, at its core, a FilterChainProxy registered as a single servlet filter. It delegates to an ordered list of security filters. Each request passes through them before it ever reaches the DispatcherServlet. This is the most important fact to understand: security happens before MVC, which is why @ControllerAdvice cannot catch 401 and 403 responses.

The key filters execute in this order:

  1. SecurityContextPersistenceFilter : loads/stores the SecurityContext.
  2. CorsFilter : handles Cross-Origin Resource Sharing.
  3. JwtAuthenticationFilter (custom) : validates the JWT token.
  4. AuthorizationFilter : checks URL-based access rules.
  5. ExceptionTranslationFilter : converts security exceptions to HTTP responses.
JAVA
package com.bank.config;

import com.bank.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
        this.jwtFilter = jwtFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())                          // stateless JWT API
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**").permitAll()    // login is open
                .requestMatchers("/actuator/health/**").permitAll() // K8s probes
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())                     // everything else needs JWT
            .addFilterBefore(jwtFilter,
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}
DIAGRAM
Request flow through security:

  HTTP Request
       │
  ┌────┴──────────────────────┐
  │ SecurityFilterChain        │  runs BEFORE DispatcherServlet
  │                            │
  │  1. CorsFilter             │
  │  2. JwtAuthenticationFilter│  validate token, set SecurityContext
  │  3. AuthorizationFilter    │  check URL rules
  │  4. ExceptionTranslation   │  convert exceptions to 401/403
  └────┬──────────────────────┘
       ▼
  ┌────────────────┐
  │ DispatcherServlet│  only reached if security passes
  └────┬───────────┘
       ▼
  Controller Method

How does JWT authentication work end-to-end?

For a stateless API, there is no HTTP session. Identity travels in the JWT token on every request. The flow has two phases: login (get the token) and access (use the token).

Phase 1: Login. Priya sends her email and password to /api/v1/auth/login. The server validates the credentials, creates a JWT containing the subject (email), roles, and expiry, signs it with a secret key, and returns it.

Phase 2: Access. Priya sends Authorization: Bearer on every subsequent request. The JwtAuthenticationFilter extracts the token, validates the signature and expiry, reads the claims, builds an Authentication object, and stores it in the SecurityContextHolder.

JAVA
package com.bank.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtDecoder jwtDecoder;

    public JwtAuthenticationFilter(JwtDecoder jwtDecoder) {
        this.jwtDecoder = jwtDecoder;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        String header = request.getHeader("Authorization");

        if (header != null && header.startsWith("Bearer ")) {
            try {
                String token = header.substring(7);
                Jwt jwt = jwtDecoder.decode(token);       // validates signature + expiry

                String email = jwt.getSubject();          // "priya@nationalbank.in"
                List<String> roles = jwt.getClaim("roles"); // ["USER", "TELLER"]

                var authorities = roles.stream()
                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                    .collect(Collectors.toList());

                var auth = new UsernamePasswordAuthenticationToken(
                    email, null, authorities);

                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (JwtException e) {
                // Invalid or expired token: do NOT set authentication
                // AuthorizationFilter will reject the request as 401
            }
        }

        chain.doFilter(request, response);
    }
}
TEXT
Login flow:
  POST /api/v1/auth/login {"email": "priya@nationalbank.in", "password": "..."}
  -> Validate credentials
  -> Create JWT: {sub: "priya@nationalbank.in", roles: ["USER"], exp: 1705400000}
  -> Sign with HMAC-SHA256 or RSA
  -> Return: {"token": "eyJhbGciOiJIUzI1NiI..."}

Access flow:
  GET /api/v1/accounts/SAV2001/balance
  Authorization: Bearer eyJhbGciOiJIUzI1NiI...
  -> JwtAuthenticationFilter validates token
  -> Sets SecurityContext with email + ROLE_USER
  -> AuthorizationFilter allows /api/v1/accounts/** for authenticated users
  -> Controller returns balance: 150000.00

What is @PreAuthorize and how does it work?

With @EnableMethodSecurity, Spring wraps secured beans in an AOP proxy that evaluates a SpEL (Spring Expression Language) expression against the current Authentication before the method runs. If the expression evaluates to false, Spring throws AccessDeniedException, which becomes a 403 Forbidden response.

JAVA
package com.bank.service;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;

@Service
public class AccountService {

    // Only users with ROLE_ADMIN can list all accounts
    @PreAuthorize("hasRole('ADMIN')")
    public List<Account> findAllAccounts() {
        return accountRepository.findAll();
    }

    // Only the account owner can view their balance
    @PreAuthorize("#accountId == authentication.name or hasRole('ADMIN')")
    public BigDecimal getBalance(String accountId) {
        return accountRepository.findBalance(accountId);
    }

    // Only users with ROLE_TELLER can process transfers
    @PreAuthorize("hasRole('TELLER')")
    public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
        // Only tellers can initiate transfers
        debit(fromAccount, amount);
        credit(toAccount, amount);
    }
}
TEXT
Priya (ROLE_USER) calls findAllAccounts() -> 403 Forbidden
Rajesh (ROLE_ADMIN) calls findAllAccounts() -> 200 OK with all accounts

Priya calls getBalance("priya@nationalbank.in") -> 200 OK (own data)
Priya calls getBalance("rajesh@nationalbank.in") -> 403 Forbidden (not owner)
Rajesh (ADMIN) calls getBalance("priya@nationalbank.in") -> 200 OK (admin override)

How is @PreAuthorize different from @Secured?

| Feature | @PreAuthorize | @Secured |
|---|---|---|
| Expression language | SpEL (powerful) | Simple role names only |
| Access to method args | Yes (#accountId) | No |
| Complex conditions | Yes (hasRole('A') and #id == auth.name) | No |
| Enabled by | @EnableMethodSecurity | @EnableMethodSecurity(securedEnabled = true) |
| Recommendation | Preferred | Legacy |

JAVA
// @Secured: simple but limited
@Secured("ROLE_ADMIN")
public List<Account> findAllAccounts() { }

// @PreAuthorize: powerful SpEL expressions
@PreAuthorize("hasRole('ADMIN') and #department == 'LOANS'")
public List<Account> findByDepartment(String department) { }

Why is CSRF disabled for JWT APIs?

CSRF (Cross-Site Request Forgery) is an attack where a malicious website tricks the browser into sending a request with the user's cookies. CSRF protection is needed for session-based authentication because the browser automatically attaches the session cookie. JWT APIs are stateless: the token is sent in the Authorization header, not in a cookie, so the browser never attaches it automatically. CSRF protection is unnecessary and is disabled.

JAVA
// In SecurityConfig
http.csrf(csrf -> csrf.disable());
// Safe because JWT is in the Authorization header, not a cookie
// The browser cannot auto-attach it like a session cookie

How do you return structured JSON errors for 401 and 403?

Because security filters run before DispatcherServlet, @ControllerAdvice cannot catch security exceptions. You need custom AuthenticationEntryPoint (for 401) and AccessDeniedHandler (for 403).

JAVA
package com.bank.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;

@Component
public class CustomAuthEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper mapper = new ObjectMapper();

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
        response.setStatus(401);
        response.setContentType("application/json");
        mapper.writeValue(response.getOutputStream(), Map.of(
            "status", 401,
            "error", "Unauthorized",
            "message", "Missing or invalid JWT token",
            "path", request.getRequestURI()
        ));
    }
}

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper mapper = new ObjectMapper();

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException ex) throws IOException {
        response.setStatus(403);
        response.setContentType("application/json");
        mapper.writeValue(response.getOutputStream(), Map.of(
            "status", 403,
            "error", "Forbidden",
            "message", "You do not have permission to access this resource",
            "path", request.getRequestURI()
        ));
    }
}
JAVA
// Register in SecurityConfig
http.exceptionHandling(ex -> ex
    .authenticationEntryPoint(customAuthEntryPoint)
    .accessDeniedHandler(customAccessDeniedHandler));
TEXT
No token:
  GET /api/v1/accounts/SAV2001/balance
  -> 401 {"status":401,"error":"Unauthorized","message":"Missing or invalid JWT token"}

Valid token, wrong role:
  GET /api/v1/admin/accounts  (Priya with ROLE_USER)
  -> 403 {"status":403,"error":"Forbidden","message":"You do not have permission..."}

What is WebClient and why is it preferred over RestTemplate?

When the account service needs to call the KYC service, it uses WebClient for inter-service HTTP calls. WebClient is the modern, non-blocking HTTP client that supports timeouts, retry, and automatic token propagation.

| Feature | RestTemplate | WebClient |
|---|---|---|
| Blocking | Yes | No (reactive) |
| Status | Maintenance mode | Actively developed |
| Timeout config | Verbose | Fluent API |
| Retry | Manual | Built-in with backoff |
| Token propagation | Manual header | Exchange filter |

JAVA
package com.bank.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient kycServiceClient() {
        HttpClient httpClient = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
            .doOnConnected(conn ->
                conn.addHandlerLast(
                    new ReadTimeoutHandler(5, TimeUnit.SECONDS)));

        return WebClient.builder()
            .baseUrl("http://kyc-service:8080")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .defaultHeader("X-Service-Name", "account-service")
            .build();
    }
}
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;

@Service
public class KycClient {

    private final WebClient kycServiceClient;

    public KycClient(WebClient kycServiceClient) {
        this.kycServiceClient = kycServiceClient;
    }

    public KycStatus checkKyc(String customerId, String jwtToken) {
        return kycServiceClient.get()
            .uri("/api/v1/kyc/{customerId}", customerId)
            .header("Authorization", "Bearer " + jwtToken)
            .retrieve()
            .bodyToMono(KycStatus.class)
            .block();    // blocking call in a non-reactive app
    }
}

What are common mistakes with Spring Security?

The most common mistake is placing security logic in the controller instead of using the filter chain and @PreAuthorize.

JAVA
// WRONG: manual security check in controller
@GetMapping("/api/v1/admin/accounts")
public List<Account> listAll(HttpServletRequest request) {
    String role = extractRoleFromToken(request);
    if (!"ADMIN".equals(role)) {
        throw new ForbiddenException();
    }
    return accountService.findAll();
}

// CORRECT: declarative security
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/v1/admin/accounts")
public List<Account> listAll() {
    return accountService.findAll();
}

Another common mistake is not catching JwtException in the filter. If the filter throws an unhandled exception, the user sees a generic 500 error instead of a clean 401.

What should a senior engineer know?

  • Use asymmetric keys (RSA or EC) for JWT signing in production. The auth service holds the private key for signing, and all other services hold only the public key for verification. This way, only the auth service can create tokens.
  • Store JWT secrets in environment variables or a secrets manager (Vault, AWS Secrets Manager), never in application.yml.
  • Set short JWT expiry (15 to 30 minutes) and use refresh tokens for long sessions. Short-lived tokens limit the damage if a token is compromised.
  • For service-to-service calls, use OAuth2 client credentials grant. The calling service obtains its own token from the authorization server, not forwarding the user's token.
  • Pankaj configures CORS explicitly in the SecurityFilterChain for the National Bank of India web application, allowing only the frontend domain.
JAVA
// CORS configuration in SecurityConfig
http.cors(cors -> cors.configurationSource(request -> {
    var config = new org.springframework.web.cors.CorsConfiguration();
    config.setAllowedOrigins(List.of("https://nationalbank.in"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    return config;
}));

What are common interview follow-up questions?

  • Why does @ControllerAdvice not catch 401 and 403? Because security filters run before DispatcherServlet. The request never reaches MVC, so @ControllerAdvice never sees it.
  • What is the difference between authentication and authorization? Authentication verifies WHO you are (JWT validation). Authorization verifies WHAT you can do (@PreAuthorize role check).
  • How do you test secured endpoints? In @WebMvcTest, use @WithMockUser(roles = "ADMIN") to simulate an authenticated user, or use MockMvc with a valid JWT in the Authorization header.
  • What is the difference between CSRF and CORS? CSRF prevents forged requests from other sites. CORS controls which origins can make cross-origin requests. Both are different security mechanisms.

Visual Overview

DIAGRAM
  Spring Security Filter Chain:

  HTTP Request
       │
  ┌────┴──────────────────────────┐
  │ FilterChainProxy               │
  │                                │
  │  ┌──────────────────────────┐  │
  │  │ CorsFilter                │  │
  │  └──────────┬───────────────┘  │
  │             ▼                  │
  │  ┌──────────────────────────┐  │
  │  │ JwtAuthenticationFilter   │  │
  │  │ Extract Bearer token      │  │
  │  │ Validate signature + exp  │  │
  │  │ Set SecurityContext       │  │
  │  └──────────┬───────────────┘  │
  │             ▼                  │
  │  ┌──────────────────────────┐  │
  │  │ AuthorizationFilter       │  │
  │  │ Check URL rules           │  │
  │  │ /auth/** -> permitAll     │  │
  │  │ /admin/** -> ROLE_ADMIN   │  │
  │  │ /** -> authenticated      │  │
  │  └──────────┬───────────────┘  │
  │             ▼                  │
  │  ┌──────────────────────────┐  │
  │  │ ExceptionTranslation      │  │
  │  │ AuthException -> 401      │  │
  │  │ AccessDenied -> 403       │  │
  │  └──────────────────────────┘  │
  └────┬──────────────────────────┘
       ▼
  ┌────────────────┐
  │ DispatcherServlet│  only reached if security passes
  └────┬───────────┘
       ▼
  ┌────────────────┐
  │ Controller       │
  │ @PreAuthorize    │  method-level check via AOP
  └────────────────┘


  JWT Login + Access Flow:

  Phase 1: Login
  ┌────────┐         ┌──────────────┐
  │ Priya   │──POST──│ /auth/login   │
  │         │ email  │               │
  │         │ pass   │ Validate creds│
  │         │        │ Create JWT    │
  │         │←─JWT───│ Sign + return │
  └────────┘         └──────────────┘

  Phase 2: Access
  ┌────────┐         ┌──────────────┐
  │ Priya   │──GET───│ /accounts/   │
  │         │ Bearer │ SAV2001      │
  │         │ JWT    │              │
  │         │        │ Validate JWT │
  │         │        │ Set Context  │
  │         │←─JSON──│ Return data  │
  └────────┘         └──────────────┘

Key Takeaway

Spring Security uses a servlet filter chain that runs before DispatcherServlet. The JwtAuthenticationFilter extends OncePerRequestFilter, extracts the Bearer token from the Authorization header, validates the signature and expiry using a JwtDecoder, reads the claims (subject, roles), and sets the authenticated principal in SecurityContextHolder. The SecurityFilterChain bean configures which paths are public (login, health probes), which require authentication, and which require specific roles.

@PreAuthorize with SpEL expressions on service methods provides fine-grained method-level authorization via AOP. At National Bank of India, the admin endpoint requires ROLE_ADMIN, and a USER like Priya gets 403 Forbidden. Custom AuthenticationEntryPoint and AccessDeniedHandler return structured JSON errors for 401 and 403, because @ControllerAdvice cannot catch them.

For inter-service calls to the KYC service, Rajesh uses WebClient with explicit connect and read timeouts and automatic token propagation. CSRF is disabled because JWT APIs are stateless and the token travels in the header, not a cookie. Pankaj uses asymmetric RSA keys for signing: the auth service holds the private key, and all other services hold only the public key for verification.

Remember one sentence: security filters run before DispatcherServlet, the JWT filter validates the token and sets the SecurityContext, and @PreAuthorize provides method-level authorization via SpEL.

---

Share
Use ← → arrow keys to navigate