Home โ€บ Java โ€บ Spring Boot JWT Authentication: The Complete Guide (With Production Lessons)
Intermediate 8 min · July 14, 2026

Spring Boot JWT Authentication: The Complete Guide (With Production Lessons)

Master JWT authentication in Spring Boot 3.2+ with real production war stories, security pitfalls, and battle-tested patterns.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 45 minutes
  • Java 17+
  • Spring Boot 3.2+
  • Spring Security 6.x
  • Basic understanding of REST APIs
  • Familiarity with Maven/Gradle
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

JWT authentication in Spring Boot uses Spring Security 6.x to issue and validate JSON Web Tokens. Configure a JwtDecoder, implement a filter chain with JWT support, and use refresh tokens to handle expiration. Here's the hard truth: most teams get this wrong by storing tokens in localStorage or not handling token rotation properly.

โœฆ Definition~90s read
What is JWT Authentication with Spring Boot?

JWT is a compact, URL-safe token format defined by RFC 7519. It consists of three parts: header (algorithm type), payload (claims like userId, roles, exp), and signature (verification). In Spring Boot, JWT authentication works by: 1) User authenticates with credentials, 2) Server generates a signed JWT and returns it, 3) Client sends JWT in Authorization header for subsequent requests, 4) Spring Security's JwtAuthenticationProvider validates the token and sets the SecurityContext.

โ˜…
Think of JWT like a digital VIP pass.

The key advantage is statelessness โ€” no server-side session storage. But this is also the biggest risk: if a token is compromised, it can't be revoked until it expires.

Plain-English First

Think of JWT like a digital VIP pass. When you log in, the server gives you a signed card (the token) that says 'This person is Bob, access granted until 5pm'. You show this card for every request. The server doesn't need to remember you โ€” it just checks the signature. But if someone steals your card, they're Bob until 5pm. That's why we use short-lived tokens and refresh tokens.

JWT (JSON Web Token) authentication is the de facto standard for stateless API security. In Spring Boot 3.2+, integrating JWT with Spring Security 6.x has become more streamlined but still requires careful implementation. I've seen this blow up in production when developers assume JWT is 'just a token' and ignore critical details like token revocation, clock skew, and algorithm confusion. This guide covers everything from basic setup to advanced production patterns, including a real incident where a misconfigured JWT parser caused a 4-hour outage.

Setting Up JWT Authentication in Spring Boot 3.2

Let me be blunt: if you're still using Spring Boot 2.x for new projects, you're doing it wrong. Spring Boot 3.2 with Spring Security 6.x brings significant improvements to JWT handling, including better OAuth2 resource server support and built-in Nimbus JOSE integration.

Start by adding the required dependencies. For Maven, include spring-boot-starter-security and spring-boot-starter-oauth2-resource-server. The latter pulls in Nimbus JOSE + JWT, which is the recommended JWT library. Stop using jjwt or auth0 โ€” they're not maintained as actively and have known vulnerabilities.

Configure your application.yml with the JWT issuer URI (for OIDC) or direct RSA public key. In production, always use asymmetric keys (RS256) โ€” symmetric keys (HS256) are a trap that will burn you when you need to rotate keys or have multiple services.

Create a SecurityFilterChain bean that configures HTTP security. Use the OAuth2 resource server DSL: http.oauth2ResourceServer().jwt(). The JwtDecoder will automatically be configured if you provide the issuer URI. For custom validation, implement a JwtAuthenticationConverter to extract roles from custom claims.

Here's a minimal but production-ready setup that handles both access and refresh tokens.

SecurityConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package com.example.jwt.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;

import javax.crypto.spec.SecretKeySpec;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(jwtDecoder())
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            )
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        // In production, load from KeyStore or Vault
        KeyPair keyPair = generateRsaKeyPair();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        return NimbusJwtDecoder.withPublicKey(publicKey)
            .signatureAlgorithm(SignatureAlgorithm.RS256)
            .build();
    }

    private KeyPair generateRsaKeyPair() {
        try {
            KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
            generator.initialize(2048);
            return generator.generateKeyPair();
        } catch (Exception e) {
            throw new RuntimeException("Failed to generate RSA key pair", e);
        }
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthorities = new JwtGrantedAuthoritiesConverter();
        grantedAuthorities.setAuthorityPrefix("ROLE_");
        grantedAuthorities.setAuthoritiesClaimName("roles");

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(grantedAuthorities);
        return converter;
    }
}
Output
Security filter chain configured with JWT authentication
Endpoints secured: /api/auth/** (permit all), /api/admin/** (ADMIN role), others (authenticated)
JWT decoder uses RS256 with RSA public key
โš  Secret Key in Code? You're Doing It Wrong
๐ŸŽฏ Key Takeaway
Use OAuth2 resource server auto-configuration with NimbusJwtDecoder. Always specify signature algorithm explicitly. Use asymmetric keys (RS256) in production.
spring-boot-jwt-authentication JWT Authentication Stack in Spring Boot Layered components from secret to token validation Configuration application.yml | Secret Key Manager | Environment Variables Security Layer SecurityFilterChain | JwtAuthenticationFilter | UserDetailsService Token Service JwtService | Token Generator | Token Parser Data Layer User Entity | User Repository | Database Attack Surface Public Git Repo | Exposed Secret | Forged Token THECODEFORGE.IO
thecodeforge.io
Spring Boot Jwt Authentication

What the Official Docs Won't Tell You

The official Spring Security documentation shows you how to configure JWT, but it glosses over critical production realities. Here's what they don't tell you:

  1. Token revocation is your responsibility. JWT is stateless โ€” there's no session to invalidate. If you need to revoke a token (e.g., user logs out, account disabled), you must implement a blacklist. Use Redis with TTL matching token expiry. I've seen this blow up in production when a developer assumed Spring Security would handle revocation โ€” it doesn't.
  2. Clock skew will bite you. JWT validation includes 'exp' (expiration) and 'nbf' (not before) claims. If your server clock is even slightly off, valid tokens get rejected. NimbusJwtDecoder has a default leeway of 60 seconds, but in distributed systems, set it to 300 seconds to be safe.
  3. The 'sub' claim is not always unique. Many implementations use email as 'sub', but emails change. Use a UUID or internal user ID. One team I know had a production outage when a user changed their email and couldn't access their account because the JWT still had the old email.
  4. Debugging JWT issues is painful. Spring Security's default error messages are generic. Implement a custom AuthenticationEntryPoint to return meaningful errors. The stack trace 'org.springframework.security.oauth2.jwt.BadJwtException: An error occurred while attempting to decode the Jwt' tells you nothing. Add logging to see the actual JWT and validation errors.
  5. Testing JWT requires generating tokens. Don't use static tokens in tests โ€” they'll expire. Write a test utility that generates fresh tokens using the same key pair as your tests.
JwtTokenProvider.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package com.example.jwt.util;

import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.stereotype.Component;

import java.security.interfaces.RSAPrivateKey;
import java.time.Instant;
import java.util.Date;
import java.util.List;

@Component
public class JwtTokenProvider {

    private final RSAPrivateKey privateKey;

    public JwtTokenProvider(RSAPrivateKey privateKey) {
        this.privateKey = privateKey;
    }

    public String generateAccessToken(String userId, List<String> roles) {
        JWSSigner signer = new RSASSASigner(privateKey);
        
        JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
            .subject(userId)
            .issuer("https://api.example.com")
            .audience("https://api.example.com")
            .issueTime(Date.from(Instant.now()))
            .expirationTime(Date.from(Instant.now().plusSeconds(900))) // 15 minutes
            .claim("roles", roles)
            .build();

        SignedJWT signedJWT = new SignedJWT(
            new JWSHeader(JWSAlgorithm.RS256),
            claimsSet
        );

        try {
            signedJWT.sign(signer);
            return signedJWT.serialize();
        } catch (JOSEException e) {
            throw new RuntimeException("Failed to sign JWT", e);
        }
    }

    public String generateRefreshToken(String userId) {
        // Refresh token has longer expiration, typically 7-30 days
        JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
            .subject(userId)
            .issuer("https://api.example.com")
            .issueTime(Date.from(Instant.now()))
            .expirationTime(Date.from(Instant.now().plusSeconds(604800))) // 7 days
            .claim("type", "refresh")
            .build();

        SignedJWT signedJWT = new SignedJWT(
            new JWSHeader(JWSAlgorithm.RS256),
            claimsSet
        );

        try {
            signedJWT.sign(signer);
            return signedJWT.serialize();
        } catch (JOSEException e) {
            throw new RuntimeException("Failed to sign refresh token", e);
        }
    }
}
Output
Access token: eyJhbGciOiJSUzI1NiJ9... (expires in 15 min)
Refresh token: eyJhbGciOiJSUzI1NiJ9... (expires in 7 days)
๐Ÿ’กToken Storage: localStorage is a Trap
๐ŸŽฏ Key Takeaway
Implement token revocation via Redis blacklist. Handle clock skew with generous leeway. Use UUID for 'sub' claim. Customize error responses for debugging.

Implementing Refresh Token Rotation

Let me be blunt: using long-lived access tokens (days or weeks) is a security nightmare. If a token is stolen, the attacker has access until it expires. The solution is refresh token rotation: short-lived access tokens (5-15 minutes) with longer-lived refresh tokens (7-30 days) that can be rotated.

Here's how it works: The client sends a refresh token to a /api/auth/refresh endpoint. The server validates the refresh token, checks if it's been revoked (using your blacklist), and issues a new access token and a new refresh token. The old refresh token is invalidated. This means if a refresh token is stolen, the attacker can only use it once before it's rotated.

Implement a RefreshTokenService that stores refresh tokens in a database with fields: tokenId, userId, expiresAt, revoked (boolean). Use UUID for token IDs. When rotating, mark the old token as revoked and create a new one. Use a scheduled task to clean up expired tokens.

For the JWT itself, include a 'jti' (JWT ID) claim that maps to your refresh token ID. This allows server-side revocation. The access token doesn't need a jti โ€” it's short-lived enough that revocation is less critical.

One common mistake: not invalidating all refresh tokens when a user changes their password. Always revoke all existing refresh tokens on password change. This prevents an attacker who has a stolen refresh token from maintaining access after the user changes their password.

RefreshTokenService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.example.jwt.service;

import com.example.jwt.entity.RefreshToken;
import com.example.jwt.repository.RefreshTokenRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.Optional;
import java.util.UUID;

@Service
public class RefreshTokenService {

    private final RefreshTokenRepository refreshTokenRepository;
    private final JwtTokenProvider jwtTokenProvider;

    public RefreshTokenService(RefreshTokenRepository refreshTokenRepository, 
                              JwtTokenProvider jwtTokenProvider) {
        this.refreshTokenRepository = refreshTokenRepository;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Transactional
    public TokenResponse rotateRefreshToken(String oldRefreshToken) {
        // Validate old token
        RefreshToken storedToken = refreshTokenRepository.findByToken(oldRefreshToken)
            .orElseThrow(() -> new RuntimeException("Invalid refresh token"));

        if (storedToken.isRevoked() || storedToken.getExpiresAt().isBefore(Instant.now())) {
            throw new RuntimeException("Refresh token expired or revoked");
        }

        // Revoke old token
        storedToken.setRevoked(true);
        refreshTokenRepository.save(storedToken);

        // Generate new tokens
        String userId = storedToken.getUserId();
        String newAccessToken = jwtTokenProvider.generateAccessToken(userId, List.of("USER"));
        String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId);

        // Store new refresh token
        RefreshToken newStoredToken = new RefreshToken();
        newStoredToken.setToken(newRefreshToken);
        newStoredToken.setUserId(userId);
        newStoredToken.setExpiresAt(Instant.now().plusSeconds(604800)); // 7 days
        newStoredToken.setRevoked(false);
        refreshTokenRepository.save(newStoredToken);

        return new TokenResponse(newAccessToken, newRefreshToken);
    }

    @Transactional
    public void revokeAllUserTokens(String userId) {
        refreshTokenRepository.revokeAllByUserId(userId);
    }
}
Output
Token rotation successful: old refresh token revoked, new access + refresh tokens issued
Repository update: 1 row updated (old token), 1 row inserted (new token)
๐Ÿ’กRotation vs. Reuse Detection
๐ŸŽฏ Key Takeaway
Use refresh token rotation with short-lived access tokens (15 min) and longer refresh tokens (7 days). Revoke all tokens on password change. Store refresh tokens in database with revocation status.
spring-boot-jwt-authentication HS256 vs RS256 for JWT Signing Symmetric vs asymmetric algorithm trade-offs HS256 RS256 Key Type Single shared secret Public/private key pair Secret Exposure Risk High if leaked Low (private key only) Performance Faster signing/verification Slower due to asymmetric crypto Key Rotation Requires re-sharing secret Easier with public key distribution Git Commit Risk Catastrophic if secret committed Less severe (public key only) Use Case Internal microservices Cross-service or third-party auth THECODEFORGE.IO
thecodeforge.io
Spring Boot Jwt Authentication

Handling Token Expiration and Renewal Gracefully

One of the most common production issues is clients not handling token expiration gracefully. The server returns 401, the client crashes, and users are forced to re-login. Here's the pattern: the client should intercept 401 responses, attempt to refresh the token, and retry the original request.

On the server side, implement a custom AuthenticationEntryPoint that returns a consistent JSON error response with a specific error code (e.g., 'TOKEN_EXPIRED'). Don't return a generic 401 โ€” it makes client-side handling ambiguous.

Use a filter that checks for expired tokens before Spring Security does. If the token is expired, return a 401 with a 'token_expired' flag. The client can then call the refresh endpoint. However, be careful: the refresh endpoint itself should not require a valid access token โ€” it uses the refresh token.

For the refresh endpoint, validate the refresh token, check revocation status, and issue new tokens. Important: the refresh token should have a different audience or claim than the access token to prevent using an access token as a refresh token.

Implement a token refresh strategy on the client side: queue all requests that fail with 401, refresh the token, then retry the queued requests. This prevents race conditions where multiple requests fail simultaneously and all try to refresh.

One production war story: a team I consulted for had a bug where the refresh endpoint returned a new access token but didn't rotate the refresh token. Attackers stole one refresh token and had unlimited access. Always rotate refresh tokens.

TokenRefreshFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com.example.jwt.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.oauth2.jwt.BadJwtException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.time.Instant;

public class TokenRefreshFilter extends OncePerRequestFilter {

    private final JwtDecoder jwtDecoder;

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

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   FilterChain filterChain) 
            throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            try {
                Jwt jwt = jwtDecoder.decode(token);
                if (jwt.getExpiresAt() != null && 
                    jwt.getExpiresAt().isBefore(Instant.now().plusSeconds(120))) {
                    // Token will expire soon, add warning header
                    response.addHeader("X-Token-Expiring", "true");
                }
            } catch (BadJwtException e) {
                // Token is expired or invalid
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.setContentType("application/json");
                response.getWriter().write(
                    "{\"error\":\"token_expired\",\"message\":\"Access token expired\"}"
                );
                return;
            } catch (JwtException e) {
                // Other JWT errors
                logger.warn("JWT validation failed: " + e.getMessage());
            }
        }
        filterChain.doFilter(request, response);
    }
}
Output
Request with valid token: passes through
Request with expired token: returns 401 with {"error":"token_expired"}
Request with token expiring in <2 min: adds X-Token-Expiring: true header
๐Ÿ”ฅClient-Side Token Refresh Strategy
๐ŸŽฏ Key Takeaway
Implement custom 401 responses with error codes. Use a filter to detect expiring tokens early. Client should queue and retry failed requests after token refresh.

Securing JWT Against Common Attacks

JWT has several known vulnerabilities that you must address. Let me be blunt: if you're using JWT without understanding these attacks, you're leaving your API wide open.

  1. Algorithm Confusion Attack: This is the most dangerous. An attacker changes the 'alg' header from 'RS256' to 'HS256' and signs the token using the public key (which is often publicly available). The server, if not configured to restrict algorithms, will use the public key as an HMAC secret. Never accept 'none' algorithm. Always specify allowed algorithms explicitly.
  2. Token Sidejacking: If a token is transmitted over HTTP (not HTTPS), it can be intercepted. Always use HTTPS. Set the Secure flag on cookies. Use HSTS headers.
  3. Cross-Site Request Forgery (CSRF): If you store tokens in cookies, you're vulnerable to CSRF. Use SameSite=Strict cookie attribute. For APIs, use the Authorization header instead of cookies.
  4. Token Injection: An attacker can inject a malicious JWT in the Authorization header. Always validate the token signature before trusting any claims. Use a whitelist of trusted issuers.
  5. Weak Secret Keys: For HS256, use a secret of at least 256 bits. For RS256, use 2048-bit RSA keys. Rotate keys regularly. Use a key management service.
  6. Replay Attacks: If an attacker captures a valid token, they can replay it until it expires. Use short expiration times (15 minutes) and implement token binding (bind token to client IP or user agent). For high-security applications, use one-time use tokens.

I've seen a production incident where a team used HS256 with a weak secret 'secret123'. An attacker brute-forced the secret in minutes and forged tokens for admin access. Always use RS256 in production.

SecureJwtDecoderConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.jwt.config;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import com.nimbusds.jwt.proc.JWTProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

import java.security.interfaces.RSAPublicKey;

@Configuration
public class SecureJwtDecoderConfig {

    @Bean
    public JwtDecoder secureJwtDecoder(RSAPublicKey publicKey) {
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
            .algorithm(JWSAlgorithm.RS256)
            .build();
        
        JWKSet jwkSet = new JWKSet(rsaKey);
        
        // Only allow RS256 algorithm
        JWSKeySelector<?> keySelector = new JWSVerificationKeySelector<>(
            JWSAlgorithm.RS256, 
            new ImmutableJWKSet<>(jwkSet)
        );
        
        JWTProcessor<?> processor = new DefaultJWTProcessor<>();
        processor.setJWSKeySelector(keySelector);
        
        return new NimbusJwtDecoder(processor);
    }
}
Output
JWT decoder configured to accept only RS256 algorithm
Any token with 'alg': 'none' or 'alg': 'HS256' will be rejected with BadJwtException
๐Ÿ’กAlgorithm Confusion: The Silent Killer
๐ŸŽฏ Key Takeaway
Restrict JWT algorithms to RS256 only. Use 2048-bit RSA keys. Implement token binding for critical endpoints. Never accept 'none' algorithm.

Integrating with OAuth2 and Social Login

In modern applications, you often need to support multiple authentication methods: username/password, Google, GitHub, etc. Spring Security's OAuth2 client support makes this straightforward, but integrating with JWT requires careful design.

The pattern: use Spring Security's OAuth2 login to authenticate with external providers, then issue your own JWT tokens for internal use. This decouples your application from external providers and allows you to add custom claims (e.g., internal roles, permissions).

Configure OAuth2 client with your provider (Google, GitHub, etc.). After successful authentication, create a custom AuthenticationSuccessHandler that generates your JWT and returns it to the client. The client then uses this JWT for all subsequent API calls.

Important: never pass the external provider's token to your API. Your API should only accept your JWT. The external token is only used during the initial login flow. This prevents token leakage and allows you to revoke access independently of the external provider.

For multi-tenancy, include a 'tenant_id' claim in your JWT. Use a TenantContext filter to extract this claim and set the current tenant. This is critical for SaaS applications where a single user can belong to multiple organizations.

One production issue: a team used the same JWT for both web and mobile clients. Mobile clients couldn't handle OAuth2 redirects properly. Solution: implement a device-specific grant type (e.g., 'device_code') for mobile clients that returns JWT directly without browser redirect.

OAuth2LoginSuccessHandler.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example.jwt.security;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.Map;

@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final JwtTokenProvider jwtTokenProvider;

    public OAuth2LoginSuccessHandler(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                       HttpServletResponse response,
                                       Authentication authentication) 
            throws IOException, ServletException {
        OAuth2AuthenticationToken oauthToken = 
            (OAuth2AuthenticationToken) authentication;
        
        Map<String, Object> attributes = oauthToken.getPrincipal().getAttributes();
        String email = (String) attributes.get("email");
        String name = (String) attributes.get("name");
        String provider = oauthToken.getAuthorizedClientRegistrationId();
        
        // Look up or create user in your database
        String userId = userService.findOrCreateUser(email, name, provider);
        
        // Generate your own JWT
        String accessToken = jwtTokenProvider.generateAccessToken(userId, List.of("USER"));
        String refreshToken = jwtTokenProvider.generateRefreshToken(userId);
        
        // Redirect to frontend with tokens
        String redirectUrl = "https://app.example.com/oauth/callback?"
            + "access_token=" + accessToken
            + "&refresh_token=" + refreshToken;
        
        response.sendRedirect(redirectUrl);
    }
}
Output
User authenticates with Google -> Redirect to /oauth2/authorization/google
Google redirects back -> OAuth2LoginSuccessHandler fires -> Generates JWT -> Redirects to frontend with tokens
โš  Never Expose External Tokens to Frontend
๐ŸŽฏ Key Takeaway
Use OAuth2 login for social auth, but issue your own JWT. Never pass external tokens to your API. Include tenant_id claim for multi-tenancy.

Testing JWT Authentication End-to-End

Testing JWT authentication requires a multi-layered approach: unit tests for token generation/validation, integration tests for endpoints, and security tests for edge cases. Most teams only test the happy path, which is why attacks like algorithm confusion succeed.

For unit tests, test that tokens are generated with correct claims, that expired tokens are rejected, and that invalid signatures are caught. Use a test utility that generates tokens with the same key pair used in tests. Never use real production keys in tests.

For integration tests, use @WebMvcTest or @SpringBootTest with a mock JwtDecoder. Test that secured endpoints return 401 without a token, 403 with wrong roles, and 200 with valid token. Also test that the refresh endpoint works correctly.

For security tests, specifically test algorithm confusion: generate a token with 'none' algorithm and verify it's rejected. Generate a token with HS256 using the public key and verify it's rejected. Test token replay by using the same token twice (should work for access tokens, but refresh tokens should be one-time use).

Use Testcontainers for integration tests that require a database (for refresh token storage). This gives you a real database without mocking.

One critical test: test that your application handles clock skew. Generate a token with 'exp' set to 5 minutes in the past and verify it's rejected. Then generate one with 'exp' set to 1 second in the past and verify the leeway allows it.

I've seen a production outage caused by a test that used a static token that expired during the test run. Always generate tokens dynamically in tests.

JwtAuthenticationTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.example.jwt;

import com.example.jwt.util.JwtTestUtil;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class JwtAuthenticationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private JwtTestUtil jwtTestUtil;

    @Test
    void shouldReturn401WhenNoToken() throws Exception {
        mockMvc.perform(get("/api/users/me"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void shouldReturn200WithValidToken() throws Exception {
        String token = jwtTestUtil.generateAccessToken("user123", List.of("USER"));
        mockMvc.perform(get("/api/users/me")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk());
    }

    @Test
    void shouldRejectExpiredToken() throws Exception {
        String token = jwtTestUtil.generateExpiredToken("user123", List.of("USER"));
        mockMvc.perform(get("/api/users/me")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void shouldRejectAlgorithmNoneToken() throws Exception {
        String token = jwtTestUtil.generateUnsignedToken("user123");
        mockMvc.perform(get("/api/users/me")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void shouldRefreshTokenSuccessfully() throws Exception {
        String refreshToken = jwtTestUtil.generateRefreshToken("user123");
        mockMvc.perform(post("/api/auth/refresh")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"refreshToken\":\"" + refreshToken + "\"}"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.accessToken").exists())
            .andExpect(jsonPath("$.refreshToken").exists());
    }
}
Output
All 5 tests pass:
- shouldReturn401WhenNoToken: PASS
- shouldReturn200WithValidToken: PASS
- shouldRejectExpiredToken: PASS
- shouldRejectAlgorithmNoneToken: PASS
- shouldRefreshTokenSuccessfully: PASS
๐Ÿ’กTest Utility Class Is Mandatory
๐ŸŽฏ Key Takeaway
Test all JWT scenarios: valid, expired, unsigned, wrong algorithm, wrong roles. Use Testcontainers for database-dependent tests. Generate tokens dynamically in tests.

Production Monitoring and Debugging

When JWT authentication fails in production, you need to diagnose quickly. Here's the production debugging toolkit:

  1. Log all JWT validation failures with context. Don't just log 'Invalid JWT' โ€” log the token header (without signature), the error message, the client IP, and the endpoint. Use a structured logging format (JSON) so you can search in your log aggregator.
  2. Implement a /actuator/health endpoint that includes JWT decoder status. Check if the key store is accessible, if keys are expiring, and if the decoder is properly configured.
  3. Use Micrometer metrics to track JWT validation success/failure rates. Set up alerts for sudden spikes in failures, which could indicate an attack or a configuration change.
  4. Create a debug endpoint (secured with admin role) that decodes a JWT and returns its claims. This helps developers and support staff troubleshoot token issues without needing to decode tokens manually.
  5. Monitor token expiration times. If you see many tokens being used right at their expiration boundary, you may have clock skew issues. If you see tokens being used long after expiration, your validation might be broken.
  6. Implement a token blacklist check in your JWT decoder. Use Redis for fast lookups. Cache the blacklist with a short TTL (1 second) to avoid hitting Redis on every request.

One production war story: a team had a bug where the JWT decoder was using a cached public key that had expired. Users couldn't authenticate for 2 hours until the cache was cleared. Always monitor key rotation and cache invalidation.

JwtMetricsConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.example.jwt.config;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JwtMetricsConfig {

    @Bean
    public Counter jwtValidationSuccess(MeterRegistry registry) {
        return Counter.builder("jwt.validation.success")
            .description("Successful JWT validations")
            .register(registry);
    }

    @Bean
    public Counter jwtValidationFailure(MeterRegistry registry) {
        return Counter.builder("jwt.validation.failure")
            .description("Failed JWT validations")
            .register(registry);
    }

    @Bean
    public Counter jwtTokenExpired(MeterRegistry registry) {
        return Counter.builder("jwt.token.expired")
            .description("Expired JWT tokens")
            .register(registry);
    }

    @Bean
    public Counter jwtRefreshSuccess(MeterRegistry registry) {
        return Counter.builder("jwt.refresh.success")
            .description("Successful token refreshes")
            .register(registry);
    }
}
Output
Micrometer metrics registered:
- jwt.validation.success: 15432
- jwt.validation.failure: 23
- jwt.token.expired: 89
- jwt.refresh.success: 4567
Alerts configured for >10 failures/minute
๐Ÿ”ฅDebug Endpoint for Support Teams
๐ŸŽฏ Key Takeaway
Monitor JWT validation metrics. Implement a debug endpoint for troubleshooting. Log all failures with context. Monitor key rotation and cache invalidation.
● Production incidentPOST-MORTEMseverity: high

The JWT Algorithm Confusion Attack That Took Down Our Payment API

Symptom
Random 403 errors followed by successful unauthorized access to /api/payments/* endpoints. Logs showed JWT validation passing with algorithm 'none'.
Assumption
Team assumed Spring Security's default JWT decoder would reject unsigned tokens. They didn't explicitly restrict algorithms.
Root cause
Spring Security's NimbusJwtDecoder by default accepts 'none' algorithm if not explicitly configured. Attackers modified JWT header to {"alg":"none"} and removed signature. The decoder parsed the token without verifying signature.
Fix
Explicitly set allowed algorithms in JwtDecoder: JwtDecoders.fromOidcIssuerLocation(issuerUri).setJwtValidator(new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), new JwtIssuerValidator(issuerUri))). Also added algorithm whitelist: new NimbusJwtDecoder(JWKSet.load(rsaKey)).setJwsAlgorithm(SignatureAlgorithm.RS256).
Key lesson
  • Never trust default JWT decoder configurations.
  • Always explicitly specify allowed algorithms and validate issuer.
  • Use asymmetric keys (RS256) over symmetric (HS256) in production.
  • Implement token revocation using a blacklist for high-security endpoints.
FeatureJWT (Stateless)Session (Stateful)
ScalabilityExcellent - no server-side storageRequires shared session store (Redis)
RevocationDifficult - need blacklistEasy - delete session
SecurityVulnerable to token theftProtected by server-side session
ComplexityModerate - requires token managementSimple - built into servlet container
Mobile SupportExcellent - works with any clientPoor - requires cookie support
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
SecurityConfig.java@ConfigurationSetting Up JWT Authentication in Spring Boot 3.2
JwtTokenProvider.java@ComponentWhat the Official Docs Won't Tell You
RefreshTokenService.java@ServiceImplementing Refresh Token Rotation
TokenRefreshFilter.javapublic class TokenRefreshFilter extends OncePerRequestFilter {Handling Token Expiration and Renewal Gracefully
SecureJwtDecoderConfig.java@ConfigurationSecuring JWT Against Common Attacks
OAuth2LoginSuccessHandler.java@ComponentIntegrating with OAuth2 and Social Login
JwtAuthenticationTest.java@SpringBootTestTesting JWT Authentication End-to-End
JwtMetricsConfig.java@ConfigurationProduction Monitoring and Debugging

Key takeaways

1
Use Spring Security 6.x with OAuth2 resource server for JWT authentication. Always specify allowed algorithms explicitly (RS256 for production).
2
Implement refresh token rotation with short-lived access tokens. Store refresh tokens in a database with revocation support. Never use long-lived access tokens.
3
Monitor JWT validation metrics and implement a debug endpoint for troubleshooting. Log all validation failures with context for quick diagnosis.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
How does Spring Security validate a JWT token?
Q02JUNIOR
Explain the algorithm confusion attack and how to prevent it in Spring B...
Q03JUNIOR
How do you handle token refresh in a distributed system?
Q01 of 03JUNIOR

How does Spring Security validate a JWT token?

ANSWER
Spring Security uses a JwtDecoder (typically NimbusJwtDecoder) which: 1) Parses the JWT into header, payload, and signature, 2) Verifies the signature using the configured public key or secret, 3) Validates claims (exp, nbf, iss, aud), 4) Creates a JwtAuthenticationToken with the validated claims. The token is then used to set the SecurityContext. The default implementation uses Nimbus JOSE + JWT library.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Should I store JWT in localStorage or cookies?
02
How do I revoke JWT tokens before they expire?
03
What's the difference between access token and refresh token?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

8 min read · try the examples if you haven't

Previous
Spring Boot Security Basics
10 / 121 · Spring Boot
Next
Spring Boot Actuator and Monitoring