Security Engineering
with Spring Boot

Production security isn't about adding @EnableWebSecurity and hoping for the best. This section teaches you how Spring Security actually works, how authentication and authorization are implemented at the framework level, and how real attackers exploit misconfigured Spring applications — so you can stop them.

Spring Security Internals

Most developers treat Spring Security as a black box — add the dependency, configure some rules, and it works. But when it doesn't work (or when security is subtly broken), you need to understand what's actually happening inside the framework.

The Filter Chain Architecture

Spring Security is a chain of servlet filters. Every HTTP request passes through this chain before reaching your controllers. The chain is the security perimeter.

Spring Security Filter Chain
HTTP Request arrives
DelegatingFilterProxy (bridges Servlet & Spring context)
FilterChainProxy (manages SecurityFilterChain list)
DisableEncodeUrlFilter
SecurityContextHolderFilter
UsernamePasswordAuthenticationFilter
BearerTokenAuthenticationFilter
SessionManagementFilter
ExceptionTranslationFilter
AuthorizationFilter (checks authorization)
DispatcherServlet → Your Controller

SecurityContext and SecurityContextHolder

Once a request is authenticated, Spring stores the authentication details in the SecurityContext, which is held in SecurityContextHolder. By default this uses ThreadLocal storage — meaning the authenticated user is available anywhere in the thread for the duration of the request.

Accessing authentication anywhere in the threadJava
// From any bean — no injection needed
SecurityContext ctx = SecurityContextHolder.getContext();
Authentication auth = ctx.getAuthentication();

String username = auth.getName();
Collection<? extends GrantedAuthority> roles = auth.getAuthorities();
Object principal = auth.getPrincipal(); // UserDetails object

// Typical pattern in a service
public void updateProfile(ProfileDTO dto) {
    String currentUser = SecurityContextHolder.getContext()
        .getAuthentication().getName();
    // use currentUser for audit logging, ownership checks, etc.
}
ThreadLocal Trap in Async Code: If you start a new thread (@Async, CompletableFuture.supplyAsync()), the SecurityContext is NOT automatically propagated. The new thread has an empty SecurityContext. Fix: use DelegatingSecurityContextExecutor or SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL).

How the FilterChainProxy Works

You don't have one filter chain — you can have many, each matching a different URL pattern. For example, your API routes might use JWT while your admin panel uses form login. Each SecurityFilterChain bean matches requests by URL pattern.

Multiple security filter chainsJava
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Chain 1: API routes — stateless JWT
    @Bean
    @Order(1)
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .build();
    }

    // Chain 2: Actuator — separate access rules
    @Bean
    @Order(2)
    public SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/actuator/**")
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().hasRole("ADMIN"))
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

DelegatingFilterProxy: Bridging Servlet and Spring

The servlet container (Tomcat) manages filters and knows nothing about the Spring application context. DelegatingFilterProxy is registered in the servlet container as a standard filter but delegates all work to a Spring bean (the FilterChainProxy). This bridge is how Spring Security integrates with the servlet lifecycle.

Authentication Architecture

Authentication is the process of verifying who you are. Spring Security's authentication architecture is designed to be extensible — the same interfaces work whether you're authenticating with username/password, JWT, OAuth2, SAML, or a certificate.

The Authentication Flow

Spring Security Authentication Flow
1. Request hits UsernamePasswordAuthenticationFilter
2. Filter creates UsernamePasswordAuthenticationToken (unauthenticated)
3. Token passed to AuthenticationManager (ProviderManager)
4. ProviderManager iterates AuthenticationProvider list
5. DaoAuthenticationProvider calls UserDetailsService.loadUserByUsername()
6. PasswordEncoder.matches() verifies the password
7. On success: authenticated token stored in SecurityContextHolder
8. On failure: AuthenticationException → 401 response
Custom UserDetailsService implementationJava
@Service
@RequiredArgsConstructor
public class AppUserDetailsService implements UserDetailsService {

    private final UserRepository userRepo;

    @Override
    public UserDetails loadUserByUsername(String email)
            throws UsernameNotFoundException {
        User user = userRepo.findByEmail(email)
            .orElseThrow(() -> new UsernameNotFoundException(
                "User not found: " + email));

        return org.springframework.security.core.userdetails.User
            .withUsername(user.getEmail())
            .password(user.getPasswordHash())   // already hashed
            .roles(user.getRole().name())        // "ADMIN" → "ROLE_ADMIN"
            .accountLocked(user.isLocked())
            .accountExpired(false)
            .credentialsExpired(false)
            .build();
    }
}

// Wire it in SecurityConfig
@Bean
public AuthenticationManager authManager(
        UserDetailsService uds, PasswordEncoder encoder) {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(uds);
    provider.setPasswordEncoder(encoder);
    return new ProviderManager(provider);
}

Custom AuthenticationProvider

For non-standard authentication (API keys, OTP, smart card), implement AuthenticationProvider directly:

API key authentication providerJava
// Custom token type
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
    private final String apiKey;
    public ApiKeyAuthenticationToken(String apiKey) {
        super(null); // no authorities yet
        this.apiKey = apiKey;
        setAuthenticated(false);
    }
    @Override public Object getCredentials() { return apiKey; }
    @Override public Object getPrincipal() { return apiKey; }
}

// Provider that validates API keys
@Component
public class ApiKeyAuthenticationProvider implements AuthenticationProvider {

    private final ApiKeyRepository apiKeyRepo;

    @Override
    public Authentication authenticate(Authentication auth)
            throws AuthenticationException {
        String key = (String) auth.getCredentials();
        ApiKey stored = apiKeyRepo.findByKeyHash(sha256(key))
            .orElseThrow(() -> new BadCredentialsException("Invalid API key"));

        if (stored.isExpired()) throw new CredentialsExpiredException("API key expired");

        List<GrantedAuthority> authorities =
            List.of(new SimpleGrantedAuthority("ROLE_" + stored.getRole()));

        ApiKeyAuthenticationToken authenticated =
            new ApiKeyAuthenticationToken(key);
        authenticated.setAuthenticated(true);
        // set authorities on the authenticated token
        return new UsernamePasswordAuthenticationToken(
            stored.getOwner(), null, authorities);
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

Authorization — Who Can Do What

Authorization answers: given that I know who you are, are you allowed to do this? Spring Security provides authorization at two levels: HTTP request level (URL-based) and method level (code-based).

HTTP Request Authorization

HttpSecurity authorization rulesJava
http.authorizeHttpRequests(auth -> auth
    // Public endpoints — no auth needed
    .requestMatchers("/api/auth/**").permitAll()
    .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
    .requestMatchers("/actuator/health", "/actuator/info").permitAll()

    // Role-based access
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .requestMatchers("/api/manager/**").hasAnyRole("ADMIN", "MANAGER")

    // Authority-based (more granular than roles)
    .requestMatchers(HttpMethod.DELETE, "/api/**").hasAuthority("product:delete")
    .requestMatchers(HttpMethod.POST, "/api/products").hasAuthority("product:write")

    // Everything else requires authentication
    .anyRequest().authenticated()
);

Method Security — @PreAuthorize

URL-based rules are coarse-grained. Method security lets you protect individual service methods with SpEL expressions evaluated against the current authentication.

Enable method security + examplesJava
// Enable in config
@Configuration
@EnableMethodSecurity   // replaces deprecated @EnableGlobalMethodSecurity
public class MethodSecurityConfig { }

// Service layer authorization
@Service
public class OrderService {

    // Simple role check
    @PreAuthorize("hasRole('ADMIN')")
    public List<Order> getAllOrders() { ... }

    // Authority check
    @PreAuthorize("hasAuthority('order:write')")
    public Order createOrder(CreateOrderRequest req) { ... }

    // Check ownership — user can only access their own orders
    @PreAuthorize("#orderId != null and @orderSecurity.isOwner(#orderId, authentication)")
    public Order getOrder(Long orderId) { ... }

    // Can access if admin OR owner
    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
    public UserProfile getProfile(String userId) { ... }

    // Filter returned collection — only return orders user owns
    @PostFilter("filterObject.userId == authentication.name")
    public List<Order> getMyOrders() { ... }

    // Filter input collection before passing to method
    @PreFilter("filterObject.userId == authentication.name")
    public void processOrders(List<Order> orders) { ... }
}

// Custom security bean (referenced as @orderSecurity above)
@Component("orderSecurity")
public class OrderSecurityService {
    public boolean isOwner(Long orderId, Authentication auth) {
        Order order = orderRepo.findById(orderId).orElseThrow();
        return order.getUserId().equals(auth.getName());
    }
}
Method security requires proxying: Like @Transactional, @PreAuthorize works via AOP proxies. Calling a secured method from within the same class bypasses the proxy and skips authorization entirely. Always call secured methods from a different bean.

GrantedAuthority — Roles vs Permissions

Spring Security uses the term GrantedAuthority for all access rights. The convention is that roles are prefixed with ROLE_. When you call hasRole("ADMIN"), Spring internally checks for ROLE_ADMIN. When you call hasAuthority("product:write"), it checks for exactly product:write — no prefix added.

Industry pattern — RBAC with fine-grained permissions: In production systems, roles are assigned to users, and fine-grained permissions are assigned to roles. User → Role → Permission. You load all three at login time and store them all as GrantedAuthority objects. This gives you both coarse-grained (hasRole("MANAGER")) and fine-grained (hasAuthority("invoice:approve")) checks without extra database queries per request.

Password Hashing

Passwords must never be stored in plaintext or with reversible encryption. They must be hashed with a slow, salted, one-way algorithm specifically designed for passwords.

BCrypt — the Default

BCrypt password encoderJava
// Register the encoder as a bean
@Bean
public PasswordEncoder passwordEncoder() {
    // strength/cost factor: 10-12 is standard for production
    // higher = slower = better, but watch CPU on registration endpoints
    return new BCryptPasswordEncoder(12);
}

// Usage in registration
@Service
public class AuthService {
    private final PasswordEncoder encoder;
    private final UserRepository userRepo;

    public User register(RegisterRequest req) {
        String hashed = encoder.encode(req.getPassword());
        // hashed looks like: $2a$12$... (60 chars, includes salt)
        return userRepo.save(new User(req.getEmail(), hashed));
    }

    // Spring calls encoder.matches() automatically during login
    // You never need to call it manually in the login flow
}

Choosing a Password Hashing Algorithm

🔑
BCrypt

Industry standard. Built-in salt. Cost factor 10–12 for most apps. Available everywhere. Choose this by default.

🛡️
Argon2

OWASP recommended. Winner of Password Hashing Competition. Memory-hard — resists GPU and ASIC attacks. Use for high-security applications.

⚙️
SCrypt

Memory-hard like Argon2. Good alternative. Used by some crypto wallets. Higher memory requirements than BCrypt.

MD5 / SHA

Never use for passwords. Fast by design — billions of hashes/second on GPU. No built-in salt. Trivially cracked with rainbow tables.

DelegatingPasswordEncoder — Supporting Migration

When you need to upgrade your hashing algorithm without forcing all users to reset their passwords, use DelegatingPasswordEncoder. It stores the algorithm identifier as a prefix in the hash.

Password encoder delegationJava
@Bean
public PasswordEncoder passwordEncoder() {
    // Stores hashes as: {bcrypt}$2a$12$..., {argon2}..., {noop}plaintext
    Map<String, PasswordEncoder> encoders = new HashMap<>();
    encoders.put("bcrypt", new BCryptPasswordEncoder(12));
    encoders.put("argon2", new Argon2PasswordEncoder(16, 32, 1, 65536, 10));
    encoders.put("noop", NoOpPasswordEncoder.getInstance()); // legacy ONLY

    // New passwords use bcrypt
    DelegatingPasswordEncoder delegate = new DelegatingPasswordEncoder("bcrypt", encoders);
    return delegate;
}

// Migration strategy:
// 1. Change encoder bean to DelegatingPasswordEncoder
// 2. On next login: matches() succeeds with old algorithm
// 3. After successful login, re-encode with new algorithm and save
// Users migrate transparently without password resets

JWT — JSON Web Tokens

JWT is the dominant mechanism for stateless API authentication. Understanding its structure, its security guarantees, and — critically — its limitations is essential for production systems.

JWT Structure

Anatomy of a JWT
HEADER . PAYLOAD . SIGNATURE
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZXMiOlsiVVNFUiJdLCJleHAiOjE3MDAwMDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header (Base64)
{ "alg": "HS256",
  "typ": "JWT" }
Payload (Base64)
{ "sub": "user123",
  "roles": ["USER"],
  "exp": 1700000000 }
Signature (HMAC)
HMAC-SHA256(
 header + "." + payload,
 secret_key)
JWT payload is NOT encrypted — it's just Base64 encoded. Anyone can decode and read the payload without the secret key. Never put sensitive data (passwords, SSNs, PII) in a JWT payload. The signature only proves the token hasn't been tampered with — it doesn't hide the contents.

JWT Authentication with Spring Boot

pom.xml — Spring OAuth2 Resource ServerXML
<!-- Spring Boot 3.x — built-in JWT support via oauth2-resource-server -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

<!-- OR: use the popular JJWT library for manual JWT handling -->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.12.3</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.12.3</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.12.3</version>
  <scope>runtime</scope>
</dependency>
JwtService.javaJava
@Service
public class JwtService {

    @Value("${app.jwt.secret}")
    private String jwtSecret;

    @Value("${app.jwt.access-token-expiry:900}")  // 15 min
    private long accessTokenExpirySeconds;

    @Value("${app.jwt.refresh-token-expiry:604800}")  // 7 days
    private long refreshTokenExpirySeconds;

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
    }

    public String generateAccessToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority).toList());
        return buildToken(claims, userDetails.getUsername(), accessTokenExpirySeconds);
    }

    public String generateRefreshToken(UserDetails userDetails) {
        return buildToken(Map.of("type", "refresh"),
            userDetails.getUsername(), refreshTokenExpirySeconds);
    }

    private String buildToken(Map<String, Object> claims, String subject, long expiry) {
        return Jwts.builder()
            .claims(claims)
            .subject(subject)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expiry * 1000))
            .signWith(getSigningKey())
            .compact();
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        return extractUsername(token).equals(userDetails.getUsername())
            && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private <T> T extractClaim(String token, Function<Claims, T> resolver) {
        Claims claims = Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
        return resolver.apply(claims);
    }
}
JwtAuthFilter.javaJava
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

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

        final String authHeader = request.getHeader("Authorization");

        // No token — let the chain continue, AuthorizationFilter will deny if needed
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        final String jwt = authHeader.substring(7);

        try {
            String username = jwtService.extractUsername(jwt);

            // Only authenticate if not already authenticated
            if (username != null &&
                    SecurityContextHolder.getContext().getAuthentication() == null) {

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                    authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (JwtException e) {
            // Invalid signature, expired, malformed — let the request through
            // AuthorizationFilter will deny it if the route requires auth
            log.warn("Invalid JWT token: {}", e.getMessage());
        }

        chain.doFilter(request, response);
    }
}
SecurityConfig.java — stateless JWT setupJava
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;
    private final UserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)  // stateless — no CSRF needed
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated())
            // Add JWT filter before UsernamePasswordAuthenticationFilter
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(
                    (req, res, e) -> res.sendError(401, "Unauthorized"))
                .accessDeniedHandler(
                    (req, res, e) -> res.sendError(403, "Forbidden")))
            .build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://app.yourdomain.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

Refresh Token Pattern

Access tokens should be short-lived (15 minutes). Refresh tokens are long-lived (days/weeks) and stored securely. This minimizes the damage window if an access token is stolen.

Refresh token endpointJava
@PostMapping("/api/auth/refresh")
public ResponseEntity<TokenResponse> refreshToken(
        @RequestBody RefreshTokenRequest req) {

    String refreshToken = req.getRefreshToken();

    // 1. Validate JWT structure and expiry
    String username = jwtService.extractUsername(refreshToken);

    // 2. Check the token exists in DB (enables server-side revocation)
    RefreshToken storedToken = refreshTokenRepo
        .findByToken(hashToken(refreshToken))
        .orElseThrow(() -> new InvalidTokenException("Refresh token not found"));

    if (storedToken.isRevoked() || storedToken.isExpired()) {
        // Possible token reuse attack — revoke entire family
        refreshTokenRepo.revokeAllByUserId(storedToken.getUserId());
        throw new SecurityException("Token reuse detected");
    }

    // 3. Rotate: revoke old refresh token, issue new pair
    storedToken.setRevoked(true);
    refreshTokenRepo.save(storedToken);

    UserDetails user = userDetailsService.loadUserByUsername(username);
    String newAccess = jwtService.generateAccessToken(user);
    String newRefresh = jwtService.generateRefreshToken(user);

    // 4. Store new refresh token hash
    refreshTokenRepo.save(new RefreshToken(storedToken.getUserId(),
        hashToken(newRefresh)));

    return ResponseEntity.ok(new TokenResponse(newAccess, newRefresh));
}
Token rotation + reuse detection: When you rotate refresh tokens, immediately revoke the old one. If someone presents a revoked refresh token, it means either the original user already rotated it (normal) or an attacker captured and is reusing a stolen token. Revoke the entire token family — forcing a full re-login. This is the industry standard pattern used by Google, GitHub, and Stripe.

OAuth2 & OpenID Connect

OAuth2 is an authorization framework — it allows a user to grant a third-party application access to resources without sharing their password. OpenID Connect (OIDC) is a thin identity layer on top of OAuth2 that standardizes user authentication.

OAuth2 Roles

👤
Resource Owner

The user who owns the data and grants permission to access it.

📱
Client

The application requesting access. Could be your frontend, mobile app, or a backend service.

🏦
Authorization Server

Issues tokens after authenticating the user. Google, GitHub, Keycloak, Auth0, Okta.

🗄️
Resource Server

Your Spring Boot API. Validates access tokens and serves protected resources.

OAuth2 Flows

Authorization Code + PKCE is the correct flow for user-facing apps (SPAs, mobile). PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks.

Auth Code + PKCE Flow
Browser → generates code_verifier (random) + code_challenge = SHA256(verifier)
Browser → redirects to Auth Server with code_challenge
Auth Server → user logs in → redirects back with authorization_code
Browser → POSTs code + code_verifier to Auth Server token endpoint
Auth Server → verifies SHA256(verifier) == challenge → issues access_token + refresh_token
Browser → calls your API with Bearer access_token
Your API → validates token signature with Auth Server's public key → serves request

Client Credentials is for machine-to-machine (M2M) communication — no user involved. Microservice A authenticating to microservice B.

Client credentials — service-to-serviceJava
# application.yml — service acting as OAuth2 client
spring:
  security:
    oauth2:
      client:
        registration:
          inventory-service:
            client-id: ${INVENTORY_CLIENT_ID}
            client-secret: ${INVENTORY_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: inventory:read,inventory:write
        provider:
          inventory-service:
            token-uri: https://auth.yourdomain.com/oauth/token

---
// RestClient with automatic token management
@Bean
public RestClient inventoryClient(OAuth2AuthorizedClientManager manager) {
    var interceptor = new OAuth2ClientHttpRequestInterceptor(manager);
    return RestClient.builder()
        .baseUrl("https://inventory-service/api")
        .requestInterceptor(interceptor)
        .build();
}

Configure your Spring Boot API as an OAuth2 Resource Server — it validates tokens issued by an external Authorization Server (Google, Keycloak, Auth0).

Resource server configurationJava
# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Auth server's JWKS endpoint — Spring fetches public keys automatically
          jwk-set-uri: https://auth.yourdomain.com/.well-known/jwks.json
          # OR: single issuer URI (Spring discovers JWKS automatically via .well-known/openid-configuration)
          issuer-uri: https://auth.yourdomain.com

---
// SecurityConfig
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())))
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .build();
}

// Map JWT claims to Spring authorities
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
    JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
    converter.setAuthoritiesClaimName("roles");   // which claim has roles
    converter.setAuthorityPrefix("ROLE_");         // prefix each role

    JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
    jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
    return jwtConverter;
}

CSRF & CORS

CSRF — Cross-Site Request Forgery

CSRF attacks trick an authenticated user's browser into making unwanted requests to your API. If a user is logged in to your bank and visits a malicious site, the malicious site can make their browser send a money transfer request.

CSRF configurationJava
// For REST APIs using stateless JWT — CSRF protection is not needed
// Reason: CSRF exploits cookies. JWT in Authorization header is same-origin only.
http.csrf(AbstractHttpConfigurer::disable);

// For session-based web apps (server-rendered, or SPA using cookies):
http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    // Required for Spring Security 6.x — explicitly handle token subscription
    .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
);

// The frontend must then read the XSRF-TOKEN cookie and send it
// as X-XSRF-TOKEN header on all mutating requests (POST/PUT/DELETE)
When to disable CSRF: Stateless JWT APIs — safe to disable. The CSRF attack vector requires cookies; JWT tokens in headers can't be sent cross-origin by malicious sites. But if your API uses cookie-based sessions (even httpOnly cookies), CSRF protection is essential — do not disable it.

CORS — Cross-Origin Resource Sharing

CORS is a browser security mechanism that blocks frontend JavaScript from reading responses from a different origin (domain/port/protocol) than the page was loaded from. Your backend must explicitly allow specific origins.

Correct CORS configurationJava
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // Explicit allowed origins — never use "*" with credentials
    config.setAllowedOrigins(List.of(
        "https://app.yourdomain.com",
        "https://admin.yourdomain.com"
    ));

    // OR: pattern matching for subdomains
    config.setAllowedOriginPatterns(List.of("https://*.yourdomain.com"));

    config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-ID"));
    config.setExposedHeaders(List.of("X-Total-Count", "X-Request-ID")); // frontend can read
    config.setAllowCredentials(true);  // required for cookies/Authorization header
    config.setMaxAge(3600L);           // preflight cache duration in seconds

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}
Never use allowedOrigins("*") with allowCredentials(true). This combination is rejected by browsers (spec violation). More importantly, it would allow any website to make credentialed requests to your API. Always specify explicit origins in production.

Session Management

Session Fixation Attacks

In a session fixation attack, the attacker provides the victim with a known session ID before authentication. When the victim logs in, the session is now authenticated — and the attacker, who knows the session ID, can use it too.

Session fixation protectionJava
http.sessionManagement(session -> session
    // CHANGE_SESSION_ID: creates new session on login — default, recommended
    .sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::changeSessionId)

    // For stateless JWT APIs:
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

    // Limit concurrent sessions — prevents session sharing/theft
    .maximumSessions(1)
    .maxSessionsPreventsLogin(false) // false = new login invalidates old session
);

Stateless vs Stateful — Architectural Choice

🔁
Stateless (JWT)

Server stores nothing. Token carries state. Scales horizontally trivially — any server can validate any request. Cannot invalidate individual tokens until expiry without a blocklist.

🗄️
Stateful (Sessions)

Session data in database or Redis. Can instantly revoke sessions. Easier to implement security features (force logout, concurrent session limiting). Requires sticky sessions or shared session store.

Production recommendation: Use short-lived JWTs (15 min) with a Redis-backed refresh token store. You get stateless scalability for most requests, plus the ability to revoke refresh tokens immediately for logout, compromised accounts, or security incidents. This is what Google, Netflix, and Stripe use.

Rate Limiting & API Security

Rate limiting prevents brute force attacks, credential stuffing, denial-of-service, and API abuse. It's one of the most overlooked production security controls.

Bucket4j — In-Process Rate Limiting

Rate limiting with Bucket4jJava
<!-- pom.xml -->
<dependency>
  <groupId>com.bucket4j</groupId>
  <artifactId>bucket4j-core</artifactId>
  <version>8.7.0</version>
</dependency>

// Rate limit filter — 20 requests per minute per IP
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RateLimitFilter extends OncePerRequestFilter {

    // ConcurrentHashMap for in-memory; swap for Redis/Hazelcast in distributed setup
    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

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

        String key = getKey(req);
        Bucket bucket = buckets.computeIfAbsent(key, k -> createBucket());

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (probe.isConsumed()) {
            res.setHeader("X-Rate-Limit-Remaining",
                String.valueOf(probe.getRemainingTokens()));
            chain.doFilter(req, res);
        } else {
            res.setStatus(429);
            res.setHeader("X-Rate-Limit-Retry-After-Seconds",
                String.valueOf(probe.getNanosToWaitForRefill() / 1_000_000_000));
            res.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
        }
    }

    private Bucket createBucket() {
        return Bucket.builder()
            .addLimit(Bandwidth.builder()
                .capacity(20)
                .refillGreedy(20, Duration.ofMinutes(1))
                .build())
            .build();
    }

    private String getKey(HttpServletRequest req) {
        // Use authenticated username if available, otherwise IP
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null && auth.isAuthenticated() &&
                !(auth instanceof AnonymousAuthenticationToken)) {
            return "user:" + auth.getName();
        }
        return "ip:" + req.getRemoteAddr();
    }
}

Login Endpoint Hardening

Hardened login endpointJava
@PostMapping("/api/auth/login")
public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest req,
                                             HttpServletRequest httpReq) {
    String ip = getClientIp(httpReq);
    String key = "login:" + ip;

    // 1. Check failed attempt count in Redis
    Integer attempts = (Integer) redisTemplate.opsForValue().get(key);
    if (attempts != null && attempts >= 5) {
        throw new TooManyAttemptsException("Account temporarily locked. Try in 15 minutes.");
    }

    try {
        // 2. Attempt authentication
        Authentication auth = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(req.getEmail(), req.getPassword()));

        // 3. Success — clear failure count
        redisTemplate.delete(key);

        UserDetails user = (UserDetails) auth.getPrincipal();
        String access = jwtService.generateAccessToken(user);
        String refresh = jwtService.generateRefreshToken(user);

        // 4. Store refresh token hash
        saveRefreshToken(user.getUsername(), refresh);

        return ResponseEntity.ok(new TokenResponse(access, refresh));

    } catch (BadCredentialsException e) {
        // 5. Increment failure count with TTL
        redisTemplate.opsForValue().increment(key);
        redisTemplate.expire(key, Duration.ofMinutes(15));
        // Return generic message — don't leak whether email exists
        throw new BadCredentialsException("Invalid credentials");
    }
}
Security by obscurity for login errors: Never distinguish "user not found" from "wrong password" in your error messages. Both should return the same generic message. An attacker can use the distinction to enumerate valid email addresses.

Common Attack Vectors & Defenses

Production Security Pitfalls

CRITICAL JWT algorithm confusion (alg:none / RS256 → HS256)

Attackers change the JWT header algorithm to "alg":"none" and remove the signature. Older JWT libraries accepted these. Also: if your server uses RS256 (asymmetric), an attacker could change the header to HS256 and sign with the public key (which is public!). Fix: explicitly specify and validate the algorithm in your JWT parser. Never accept "none".

Defense — explicit algorithm validationJava
// JJWT 0.12 — explicitly require HS256
Jwts.parser()
    .requireAlgorithm(Jwts.SIG.HS256)  // throws if alg differs
    .verifyWith(secretKey)
    .build()
    .parseSignedClaims(token);
CRITICAL Storing JWT in localStorage — XSS exposure

localStorage is accessible to any JavaScript on the page — including injected scripts (XSS). A single XSS vulnerability anywhere in your frontend exposes all stored tokens. Better option: store access tokens in memory (JavaScript variable) and refresh tokens in httpOnly secure cookies. httpOnly cookies cannot be read by JavaScript — only sent automatically on requests.

HIGH Insecure direct object references (IDOR)

Exposing sequential database IDs (/api/orders/1234) lets attackers enumerate resources. Always verify ownership in service methods, not just authentication. @PreAuthorize("@orderSecurity.isOwner(#id, authentication)") is the fix — never trust that "they're authenticated" means "they own this resource".

HIGH permitAll() on non-public endpoints

Using .anyRequest().permitAll() as a catch-all instead of .anyRequest().authenticated(). This means any new endpoint added to the application is public by default until someone explicitly secures it. Default-deny (anyRequest().authenticated()) is the safe default — explicitly open what needs to be public.

HIGH Weak JWT secret

Using a short or predictable JWT secret ("secret", "myapp123"). HMAC-SHA256 with a weak secret can be brute-forced. Use at minimum a 256-bit (32 byte) cryptographically random secret. Generate with: openssl rand -base64 32. Store in environment variable, never in source code.

MEDIUM Overly long JWT expiry

Setting access token expiry to hours or days. If a token is stolen, the attacker has that entire window. 15 minutes is the industry standard for access tokens. Use refresh tokens for long-term access — they can be rotated and revoked. Short access tokens are a defense-in-depth measure: even if intercepted, the damage window is minimal.

Security Headers

Spring Security sets sensible security headers by default. Know what they are and why.

Production security headersJava
http.headers(headers -> headers
    // Prevent clickjacking — disallow embedding in iframes
    .frameOptions(frame -> frame.deny())

    // Force HTTPS for 1 year — includes subdomains
    .httpStrictTransportSecurity(hsts -> hsts
        .includeSubDomains(true)
        .maxAgeInSeconds(31536000))

    // Prevent MIME type sniffing
    .contentTypeOptions(Customizer.withDefaults())

    // Referrer policy — don't leak full URL to external sites
    .referrerPolicy(ref -> ref.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))

    // Content Security Policy — restrict script sources
    .contentSecurityPolicy(csp -> csp
        .policyDirectives("default-src 'self'; " +
                          "script-src 'self' 'nonce-{NONCE}'; " +
                          "style-src 'self' https://fonts.googleapis.com; " +
                          "img-src 'self' data:; " +
                          "frame-ancestors 'none'"))
);

Method Security — Advanced Patterns

Custom Security Expressions

Custom security expression via MethodSecurityExpressionHandlerJava
// Extend default expressions with custom methods
public class CustomMethodSecurityExpressionRoot
        extends SecurityExpressionRoot
        implements MethodSecurityExpressionOperations {

    private final TenantService tenantService;
    private Object filterObject;
    private Object returnObject;

    public CustomMethodSecurityExpressionRoot(Authentication auth,
                                               TenantService tenantService) {
        super(auth);
        this.tenantService = tenantService;
    }

    // Custom expression: @PreAuthorize("belongsToTenant(#tenantId)")
    public boolean belongsToTenant(Long tenantId) {
        String currentUser = getAuthentication().getName();
        return tenantService.userBelongsToTenant(currentUser, tenantId);
    }

    // Custom expression: @PreAuthorize("hasPlan('ENTERPRISE')")
    public boolean hasPlan(String planName) {
        return getAuthentication().getAuthorities().stream()
            .anyMatch(a -> a.getAuthority().equals("PLAN_" + planName));
    }

    @Override public Object getFilterObject() { return filterObject; }
    @Override public void setFilterObject(Object o) { filterObject = o; }
    @Override public Object getReturnObject() { return returnObject; }
    @Override public void setReturnObject(Object o) { returnObject = o; }
    @Override public Object getThis() { return this; }
}

// Register the custom expression handler
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
    DefaultMethodSecurityExpressionHandler handler =
        new DefaultMethodSecurityExpressionHandler() {
            @Override
            protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
                    Authentication auth, MethodInvocation mi) {
                return new CustomMethodSecurityExpressionRoot(auth, tenantService);
            }
        };
    return handler;
}

Complete @PreAuthorize Example — Multi-Tenant API

Multi-tenant service with method securityJava
@Service
public class ProjectService {

    // Only project members can view
    @PreAuthorize("@projectSecurity.isMember(#projectId, authentication) " +
                  "or hasRole('ADMIN')")
    public Project getProject(Long projectId) { ... }

    // Only project OWNER or admin can delete
    @PreAuthorize("@projectSecurity.isOwner(#projectId, authentication) " +
                  "or hasRole('ADMIN')")
    @Transactional
    public void deleteProject(Long projectId) { ... }

    // Return only projects the user can see (post-filter)
    @PostFilter("@projectSecurity.isMember(filterObject.id, authentication) " +
                "or hasRole('ADMIN')")
    public List<Project> getAllProjects() {
        return projectRepo.findAll(); // Spring filters the result
    }
}

@Component("projectSecurity")
@RequiredArgsConstructor
public class ProjectSecurityService {
    private final ProjectMemberRepository memberRepo;

    public boolean isMember(Long projectId, Authentication auth) {
        return memberRepo.existsByProjectIdAndUserEmail(projectId, auth.getName());
    }

    public boolean isOwner(Long projectId, Authentication auth) {
        return memberRepo.existsByProjectIdAndUserEmailAndRole(
            projectId, auth.getName(), "OWNER");
    }
}
Knowledge Check
You generate a JWT access token with a 15-minute expiry. A user's account is compromised and you want to invalidate all their sessions immediately. What is the correct approach?
Change the JWT secret — this will invalidate all tokens for all users
Wait 15 minutes — the access token will expire naturally
Maintain a server-side token blocklist (in Redis) checked on every request, AND revoke all refresh tokens for that user in the database
Add the user's ID to a "suspended" flag in the database — Spring Security will automatically check this
Correct. JWTs are stateless — the server has no built-in mechanism to revoke them before expiry. To immediately invalidate a compromised account: (1) revoke all refresh tokens in the DB (prevents token rotation), and (2) add current access tokens to a Redis blocklist checked in your JWT filter. Also re-check user status in UserDetailsService — Spring Security will call it on token validation and throw if the account is locked. Changing the JWT secret invalidates ALL users' tokens — avoid this except as a last resort.

Interview Questions

Q: How does Spring Security's filter chain work? What is DelegatingFilterProxy?
Spring Security is implemented as a chain of servlet filters. Every HTTP request passes through this chain before reaching the DispatcherServlet. DelegatingFilterProxy bridges the servlet container (Tomcat) with the Spring application context — it's registered as a standard servlet filter but delegates to the FilterChainProxy Spring bean. FilterChainProxy manages multiple SecurityFilterChain instances, each matching different URL patterns. The matching chain processes the request through its filters (SecurityContextHolderFilter → AuthenticationFilter → AuthorizationFilter → etc.).
Q: Explain the difference between @Secured, @RolesAllowed, and @PreAuthorize.
All three enable method-level security. @Secured is Spring-specific, simple role checking: @Secured("ROLE_ADMIN") — no SpEL. @RolesAllowed is the JSR-250 standard annotation — same capability as @Secured, works outside Spring. @PreAuthorize is the most powerful — it evaluates SpEL expressions before method execution, supports complex conditions (hasRole() and @bean.method(#param)), access to method arguments, and custom expression roots. @PostAuthorize evaluates after execution (can inspect the return value). In modern Spring Boot applications, prefer @PreAuthorize for its expressiveness.
Q: What is the SecurityContextHolder and how does it work in async code?
SecurityContextHolder stores the current user's authentication in a ThreadLocal — meaning it's available anywhere in the current thread without explicit passing. Works perfectly for synchronous request handling. In async code (@Async methods, CompletableFuture tasks), the new thread starts with an empty SecurityContext — the ThreadLocal is not automatically propagated. Fixes: use DelegatingSecurityContextExecutor to wrap the executor (copies SecurityContext to child threads), or use SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL) (InheritableThreadLocal propagates to child threads automatically, but leaks across thread pool reuse). DelegatingSecurityContextExecutor is the safer production choice.
Q: What are the security implications of JWT and how do you handle token revocation?
JWTs are stateless and self-contained — the server doesn't need to query a database on every request. The trade-off: once issued, they cannot be revoked until expiry. Implications: (1) use short expiry (15 min) to minimize the stolen-token window; (2) for immediate revocation (logout, account suspension), maintain a Redis blocklist of revoked access token JTIs, checked in the JWT filter on every request — this adds a Redis lookup but remains much cheaper than a DB query; (3) store refresh tokens in the database so they can be revoked immediately; (4) implement refresh token rotation to detect reuse.
Q: Explain the Authorization Code with PKCE flow. Why is PKCE needed?
Authorization Code flow: user redirects to auth server, logs in, gets an authorization code, exchanges the code for tokens. PKCE (Proof Key for Code Exchange) solves authorization code interception: in mobile/SPA apps, the redirect URI can potentially be intercepted by a malicious app. PKCE: before the flow, the client generates a random code_verifier and computes code_challenge = SHA256(verifier). Sends only the challenge to the auth server. When exchanging the code for tokens, sends the original verifier. Auth server verifies SHA256(verifier) == challenge. An intercepted authorization code is useless without the code_verifier — which never traveled over the network. PKCE is now required for all public clients (SPAs, mobile).
Q: What is the difference between OAuth2 and OpenID Connect?
OAuth2 is an authorization framework — it allows applications to access resources on behalf of a user. It issues access tokens but defines nothing about the user's identity. OpenID Connect (OIDC) is an identity layer built on OAuth2. It adds the ID token (a JWT containing user identity claims: sub, email, name, etc.) and a standardized UserInfo endpoint. OAuth2 answers: "can this app access this resource?" OIDC answers: "who is this user?" In Spring Boot: spring-boot-starter-oauth2-client handles both. The ID token is used for authentication (who are you?), the access token for authorization (what can you do?).
Q: How would you implement a "logout all devices" feature?
With stateless JWTs, logout from all devices requires: (1) Store a per-user token version (a counter or timestamp) in Redis/DB; (2) embed the current version in every JWT at issuance; (3) in the JWT filter, after validating the signature and expiry, also fetch the user's current version from cache and compare — if the token's version is older than the current version, reject it; (4) "logout all devices" = increment the user's version. All existing tokens are now invalid because their embedded version is stale. Redis TTL for the version should match the max JWT expiry. This adds one Redis read per request but enables instant global logout.
Q: What security headers should a production Spring Boot API return and why?
Key headers: (1) Strict-Transport-Security — tells browsers to only use HTTPS, preventing SSL stripping attacks; (2) X-Content-Type-Options: nosniff — prevents MIME type sniffing attacks; (3) X-Frame-Options: DENY — prevents clickjacking; (4) Content-Security-Policy — restricts which scripts/styles/resources can load, mitigating XSS; (5) Referrer-Policy: strict-origin-when-cross-origin — prevents leaking URL parameters to external sites. Spring Security sets reasonable defaults for many of these. CSP requires customization for your specific app. For APIs (no browser rendering), the most critical headers are HSTS, X-Content-Type-Options, and CORS headers.
🔐

Section 06 Complete

You now understand Spring Security at the architectural level — filter chains, authentication providers, JWT internals, OAuth2 flows, and how attackers exploit misconfigured security. You can build production-grade secure APIs that don't just add annotations but actually understand the threat model.