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.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Java 17+
- ✓Spring Boot 3.2+
- ✓Spring Security 6.x
- ✓Basic understanding of REST APIs
- ✓Familiarity with Maven/Gradle
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.
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.
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:
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
Production Monitoring and Debugging
When JWT authentication fails in production, you need to diagnose quickly. Here's the production debugging toolkit:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
The JWT Algorithm Confusion Attack That Took Down Our Payment API
JwtTimestampValidator(), new JwtIssuerValidator(issuerUri))). Also added algorithm whitelist: new NimbusJwtDecoder(JWKSet.load(rsaKey)).setJwsAlgorithm(SignatureAlgorithm.RS256).- 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.
| File | Command / Code | Purpose |
|---|---|---|
| SecurityConfig.java | @Configuration | Setting Up JWT Authentication in Spring Boot 3.2 |
| JwtTokenProvider.java | @Component | What the Official Docs Won't Tell You |
| RefreshTokenService.java | @Service | Implementing Refresh Token Rotation |
| TokenRefreshFilter.java | public class TokenRefreshFilter extends OncePerRequestFilter { | Handling Token Expiration and Renewal Gracefully |
| SecureJwtDecoderConfig.java | @Configuration | Securing JWT Against Common Attacks |
| OAuth2LoginSuccessHandler.java | @Component | Integrating with OAuth2 and Social Login |
| JwtAuthenticationTest.java | @SpringBootTest | Testing JWT Authentication End-to-End |
| JwtMetricsConfig.java | @Configuration | Production Monitoring and Debugging |
Key takeaways
Interview Questions on This Topic
How does Spring Security validate a JWT token?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Spring Boot. Mark it forged?
8 min read · try the examples if you haven't