JWT Authentication with Spring Boot: A Professional Guide to Stateless Security
- JWT enables stateless authentication by moving session state from the server to the client — any server can validate the token without a shared session store.
- A JWT has three parts: Header (algorithm), Payload (claims), and Signature (cryptographic proof). The payload is Base64-encoded, not encrypted — never put sensitive data in it.
- Use HS256 for single-service architectures (one shared secret). Use RS256 for microservices (private key signs, public key verifies — resource servers can't forge tokens).
- JWT enables stateless authentication by moving session state from server to client — any server can validate the token independently
- A JWT has three parts: Header (algorithm), Payload (claims), Signature (cryptographic proof) — the payload is Base64-encoded, not encrypted
- HS256 uses one shared secret for signing and verifying — RS256 uses a private key to sign and a public key to verify
- Access tokens should be short-lived (15-60 min) to limit stolen token damage — refresh tokens are long-lived (7-30 days) for re-authentication
- Spring Security 6 requires SessionCreationPolicy.STATELESS and JwtAuthenticationFilter before UsernamePasswordAuthenticationFilter
- The most common production failure: JwtAuthenticationFilter throws on missing tokens instead of silently skipping — breaks all public endpoints
Token validation fails — 401 on every authenticated request
echo '<TOKEN>' | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .curl -s http://localhost:8080/actuator/health | jq .components.jwtSecurityHealthIntermittent 401 errors — token works on some requests but fails on others
kubectl get pods -o jsonpath='{.items[*].spec.containers[*].env[?(@.name=="JWT_SECRET")].value}'kubectl exec <pod> -- printenv JWT_SECRET | head -c 10Application crashes on first login — ClassNotFoundException or signing key error
mvn dependency:tree | grep jjwtecho '$JWT_SECRET' | base64 -d | wc -cBrute-force login attempts — high volume of 401 on /auth/login
kubectl logs <pod> | grep '/auth/login' | grep '401' | wc -lkubectl logs <pod> --since=1m | grep '/auth/login' | tail -20Production Incident
Production Debug GuideWhen JWT authentication behaves unexpectedly in production, here is how to go from observable symptom to resolution. Don't start by reading logs — start by checking the most likely cause for the specific status code you're seeing.
JwtService.isTokenValid() to print the username comparison result.SecurityContextHolder.getContext().getAuthentication().getAuthorities() in the controller to see what authorities are actually set at request time.Stateful session authentication creates a scaling bottleneck — every request requires a database or cache lookup to validate the session. At 2,000 requests per second across 8 server instances, Redis-backed sessions spiked from 2ms to 300ms latency during peak traffic. JWT-based stateless auth eliminated the shared session store entirely — the same app scaled to 20 instances with p99 response times dropping from 3 seconds to 52ms.
JWT (JSON Web Token) is the standard for stateless authentication — the token itself contains everything the server needs: who the user is, what they can do, and when it expires. The server validates the cryptographic signature and trusts the claims inside. No database lookup. No shared session cache. No Redis cluster to maintain.
But JWT is not free of trade-offs. Token revocation is harder. The payload is visible to anyone. Weak signing keys turn a cryptographic guarantee into security theater. This guide covers the complete picture — JWT structure, signing algorithms (HS256 vs RS256), the full Spring Boot 3.2+ implementation with Spring Security 6, token refresh flows, client-side storage security, and the production mistakes I've seen take down real systems.
Every section reflects patterns that hold up in production — not just in tutorials.
JWT Structure: What's Inside That Token
A JWT is three Base64URL-encoded strings separated by dots: HEADER.PAYLOAD.SIGNATURE. You can inspect any JWT by pasting it into jwt.io — the signature is verified locally in your browser, and the payload is displayed in plaintext. This is critical: the payload is NOT encrypted, only encoded. Anyone with the token can read the claims.
Header — Contains metadata about the token: the signing algorithm (HS256, RS256) and the token type (JWT). Example: {"alg":"HS256","typ":"JWT"}
Payload — The claims: statements about the user and metadata. Standard claims include: sub (subject — user ID or email), iss (issuer — who issued the token), iat (issued at), exp (expiration), jti (unique token ID for blacklisting). Custom claims are your application-specific data: roles, tenant_id, permissions. Keep this small — the token is sent on every request.
Signature — Created by taking the encoded header and encoded payload, concatenating them with a dot, and signing with the secret key (HS256) or private key (RS256). The signature is what prevents tampering — if anyone modifies the payload, the signature no longer matches and the token is rejected.
One thing worth repeating: the payload being visible is by design, not a flaw. JWT was never intended to be a confidentiality mechanism. It proves authenticity — it does not provide secrecy. If you need the payload to be private, look at JWE (JSON Web Encryption), but in most applications the right answer is to simply not put secrets in the payload.
package io.thecodeforge.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import java.security.Key; import java.util.Date; public class JwtStructureDemo { // Generated with: openssl rand -base64 32 // In production this comes from ${JWT_SECRET} environment variable — never hardcoded private static final String SECRET_KEY = "dGhpcyBpcyBhIDI1NiBiaXQgc2VjcmV0IGtleSBmb3Igand0IHNpZ25pbmc="; public static void main(String[] args) { Key signingKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_KEY)); // Build a token with standard + custom claims String token = Jwts.builder() .setSubject("alice@thecodeforge.io") // who the token represents .setIssuer("thecodeforge-auth-service") // who issued it .setAudience("thecodeforge-api") // intended recipient .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 3600000)) // 1 hour .setId("tok-" + System.currentTimeMillis()) // unique ID for blacklisting .claim("roles", "ROLE_ADMIN,ROLE_USER") // custom claim — authorization .claim("tenant_id", "tenant-001") // custom claim — multi-tenancy .signWith(signingKey, SignatureAlgorithm.HS256) .compact(); System.out.println("Token: " + token); System.out.println(); // Parse and read claims — this is what the filter does on every request Claims claims = Jwts.parserBuilder() .setSigningKey(signingKey) .build() .parseClaimsJws(token) .getBody(); System.out.println("Subject: " + claims.getSubject()); System.out.println("Issuer: " + claims.getIssuer()); System.out.println("Expires: " + claims.getExpiration()); System.out.println("Token ID: " + claims.getId()); System.out.println("Roles: " + claims.get("roles")); System.out.println("Tenant: " + claims.get("tenant_id")); System.out.println(); // Demonstrate tamper detection — this is the entire security guarantee String tamperedToken = token.substring(0, token.length() - 5) + "XXXXX"; try { Jwts.parserBuilder() .setSigningKey(signingKey) .build() .parseClaimsJws(tamperedToken); } catch (Exception e) { System.out.println("Tampered token rejected: " + e.getClass().getSimpleName()); System.out.println("Message: " + e.getMessage()); } } }
Subject: alice@thecodeforge.io
Issuer: thecodeforge-auth-service
Expires: Mon Apr 15 15:00:00 BST 2026
Token ID: tok-1744714800000
Roles: ROLE_ADMIN,ROLE_USER
Tenant: tenant-001
Tampered token rejected: SignatureException
Message: JWT signature does not match locally computed signature.
- Header specifies the algorithm (HS256, RS256) and token type — always JWT
- Payload contains claims: sub (user ID), exp (expiration), roles (authorization) — anyone can decode these with a single base64 decode command
- Signature is the cryptographic proof — modifying header or payload invalidates the signature entirely
- Never put passwords, API keys, SSNs, credit card numbers, or any PII in the payload — every proxy, load balancer, and access log that touches the request can see it
- The jti claim (unique token ID) enables server-side blacklisting of stolen tokens — include it if revocation is a requirement
HS256 vs RS256: Choosing the Right Signing Algorithm
JWT supports two families of signing algorithms: symmetric (HMAC) and asymmetric (RSA/ECDSA). The choice isn't just a performance decision — it determines your key management architecture and the blast radius of a key compromise.
HS256 (HMAC-SHA256) — Symmetric. One shared secret key signs AND verifies. Simple, fast, and operationally sufficient when a single service both issues and validates tokens. The risk: every service that validates tokens needs a copy of the secret. If any one of those services is compromised, an attacker gets a key that can forge tokens accepted by every other service.
RS256 (RSA-SHA256) — Asymmetric. A private key signs tokens on the auth server; a public key verifies them on resource servers. The private key never leaves the auth server. Resource servers hold only the public key — which cannot forge tokens, only verify them. A compromised resource server exposes the public key (which is already public) but not the signing capability.
ES256 (ECDSA-P256) — Asymmetric, like RS256 but with smaller keys and significantly faster signing and verification. A 256-bit ECDSA key provides equivalent security to a 3072-bit RSA key. For new systems with no legacy constraints, ES256 is the correct default.
The practical rule: if exactly one service issues and validates tokens, HS256 is simpler and fine. If more than one service validates tokens, use RS256 or ES256 — the asymmetric model keeps the signing capability isolated to a single point.
package io.thecodeforge.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import java.security.Key; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; import java.util.Date; public class AlgorithmComparison { public static void main(String[] args) throws Exception { // ========== HS256: Symmetric — one key signs AND verifies ========== String sharedSecret = "dGhpcyBpcyBhIDI1NiBiaXQgc2VjcmV0IGtleSBmb3Igand0IHNpZ25pbmc="; Key hmacKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(sharedSecret)); String hs256Token = Jwts.builder() .setSubject("alice@thecodeforge.io") .setIssuer("thecodeforge-auth") .setExpiration(new Date(System.currentTimeMillis() + 3600000)) .signWith(hmacKey, SignatureAlgorithm.HS256) .compact(); // Same key used to verify — every service that validates needs this secret Claims hs256Claims = Jwts.parserBuilder() .setSigningKey(hmacKey) .build() .parseClaimsJws(hs256Token) .getBody(); System.out.println("HS256 subject: " + hs256Claims.getSubject()); System.out.println("HS256 token length: " + hs256Token.length() + " chars"); System.out.println(); // ========== RS256: Asymmetric — private key signs, public key verifies ========== // In production: load from a PEM file or secrets manager, don't generate each time KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); keyPairGen.initialize(2048); KeyPair rsaKeyPair = keyPairGen.generateKeyPair(); PrivateKey privateKey = rsaKeyPair.getPrivate(); // stays on auth server only PublicKey publicKey = rsaKeyPair.getPublic(); // distributed to all resource servers String rs256Token = Jwts.builder() .setSubject("alice@thecodeforge.io") .setIssuer("thecodeforge-auth") .setExpiration(new Date(System.currentTimeMillis() + 3600000)) .signWith(privateKey, SignatureAlgorithm.RS256) .compact(); // Public key verifies — a compromised resource server cannot forge tokens Claims rs256Claims = Jwts.parserBuilder() .setSigningKey(publicKey) .build() .parseClaimsJws(rs256Token) .getBody(); System.out.println("RS256 subject: " + rs256Claims.getSubject()); System.out.println("RS256 token length: " + rs256Token.length() + " chars"); // Attempt to use public key for signing — this fails with a meaningful error try { Jwts.builder() .setSubject("attacker@evil.io") .signWith(publicKey, SignatureAlgorithm.RS256) .compact(); } catch (Exception e) { System.out.println("\nCannot sign with public key: " + e.getClass().getSimpleName()); } } }
HS256 token length: 211 chars
RS256 subject: alice@thecodeforge.io
RS256 token length: 489 chars
Cannot sign with public key: ClassCastException
- HS256: one shared secret signs AND verifies — distribute the secret to every validator and you distribute the ability to forge
- RS256: private key signs on auth server only, public key verifies on resource servers — public key exposure doesn't enable forgery
- ES256: same asymmetric model as RS256 but smaller keys and faster operations — the right default for systems built in 2025 and beyond
- If you have more than one service validating tokens, use RS256 or ES256 — HS256 does not compose securely across service boundaries
- Auth0, Keycloak, and Okta all default to RS256 with a JWKS endpoint for exactly this reason — they serve tokens to arbitrary third-party services
Project Setup: Dependencies and Configuration
Before writing any code, you need the right dependencies and configuration. JWT authentication in Spring Boot requires three things: the JJWT library for token operations, Spring Security for the filter chain, and a secret key stored securely.
The JJWT library (io.jsonwebtoken) is the standard Java library for JWT operations. Version 0.12.x is the current stable release and is the version you should be on — the 0.11.x API had breaking changes, and online examples mixing the two will cause subtle compile-time and runtime failures.
Spring Security 6 (included in Spring Boot 3.2+) changed the configuration API significantly — the old WebSecurityConfigurerAdapter is gone entirely, replaced by SecurityFilterChain beans. If you find examples still using WebSecurityConfigurerAdapter, they're targeting Spring Boot 2.x and the config will not compile in 3.x.
The secret key must be at least 256 bits (32 bytes) for HS256. JJWT enforces this — a key shorter than 256 bits throws WeakKeyException at token generation time, not at startup. Generate a key with: openssl rand -base64 32. Store it in an environment variable or secrets manager — never in application.yml that gets committed to git.
<dependencies>
<!-- Web layer: REST controllers, embedded Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security: filter chain, authentication, authorization -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JJWT: all three artifacts are REQUIRED — api alone is just interfaces -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope> <!-- implementation loaded at runtime via SPI -->
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope> <!-- JSON serialization of claims -->
</dependency>
<!-- JPA + H2 for the user repository (swap H2 for PostgreSQL in production) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok: reduces boilerplate for entities and services -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Validation: @Valid on request bodies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Actuator: exposes /actuator/health for the SecurityHealthIndicator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
All three JJWT artifacts present: jjwt-api, jjwt-impl, jjwt-jackson.
Jwts.builder() throws NoSuchAlgorithmException or ClassNotFoundException at runtime — the app compiles perfectly but crashes on the first token operation. Without jjwt-jackson, custom claim serialization fails with a JsonProcessingException. Always include all three artifacts. Always verify with mvn dependency:tree | grep jjwt.Configuration: application.yml and Secret Key Management
The JWT configuration goes in application.yml. The critical values are the secret key, the access token expiration, and the refresh token expiration. The secret key must decode to at least 32 bytes (256 bits) for HS256 — JJWT enforces this at token generation time with a WeakKeyException.
In production, never hardcode the secret key in application.yml. The ${JWT_SECRET} environment variable pattern with a fallback is intentional — the fallback exists for local development only and must never reach a production environment. The right enforcement mechanism is a startup health check that rejects known default values.
Separate expiration values for access tokens and refresh tokens reflect different security requirements: access tokens expire in minutes because they travel on every API request; refresh tokens expire in days because they're stored in a protected location and used infrequently. Shorter access token TTLs directly reduce the stolen-token damage window.
application:
security:
jwt:
# In production: set JWT_SECRET environment variable — never use the fallback value
# Generate with: openssl rand -base64 32
# Must decode to at least 32 bytes — JJWT enforces this with WeakKeyException
secret-key: ${JWT_SECRET:dGhpcyBpcyBhIHZlcnkgc2VjdXJlIHNlY3JldCBrZXkgZm9yIGp3dA==}
expiration: 900000 # 15 minutes — access token
refresh-token:
expiration: 604800000 # 7 days — refresh token
spring:
jpa:
hibernate:
ddl-auto: update
show-sql: false # set to true in dev only — leaks query structure in prod logs
datasource:
url: jdbc:h2:mem:testdb # replace with PostgreSQL in production
driver-class-name: org.h2.Driver
management:
endpoints:
web:
exposure:
include: health # expose only health — never expose env or beans in production
endpoint:
health:
show-details: when-authorized
logging:
level:
io.thecodeforge.security: DEBUG # remove DEBUG in production
org.springframework.security: INFO
Access token TTL: 15 minutes (900,000ms).
Refresh token TTL: 7 days (604,800,000ms).
JWT_SECRET read from environment variable.
User Entity, Repository, and UserDetailsService
Spring Security needs a way to load user details from your data store. This is the UserDetailsService — an interface with one method: loadUserByUsername(). Your implementation queries your database and returns a UserDetails object containing the username, password hash, and authorities (roles).
The User entity implements Spring Security's UserDetails interface directly. This keeps the design simple for single-service applications — you're not mapping between two parallel user representations. For larger systems where the security model is more complex, you might separate the JPA entity from the UserDetails implementation, but start simple and refactor when you have a real reason.
Passwords are stored as BCrypt hashes. BCrypt has a configurable work factor (cost parameter) — the default of 10 takes roughly 100ms per hash on modern hardware. That's intentional: it makes brute-force attacks computationally expensive. A database full of BCrypt hashes with work factor 12 (250ms per hash) gives attackers roughly 4 attempts per second per cracking machine — viable security against even well-resourced attackers.
package io.thecodeforge.security; import jakarta.persistence.*; import lombok.*; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor @Entity @Table(name = "users", indexes = @Index(name = "idx_users_email", columnList = "email")) // email lookup is hot path public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(unique = true, nullable = false, length = 255) private String email; @Column(nullable = false, length = 100) private String firstname; @Column(length = 100) private String lastname; @Column(nullable = false, length = 60) // BCrypt output is always 60 characters private String password; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private Role role; @Override public Collection<? extends GrantedAuthority> getAuthorities() { // In practice you may want to return multiple authorities for fine-grained RBAC return List.of(new SimpleGrantedAuthority(role.name())); } @Override public String getUsername() { return email; // email is the unique identifier used for authentication } @Override public String getPassword() { return password; } // For production systems, drive these from database columns rather than returning true @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
BCrypt.checkpw() to verify the submitted password. The UserDetails authorities (roles) are also what gets placed into the SecurityContext after a successful JWT validation.JwtService: Token Generation, Parsing, and Validation
The JwtService is the core class — it handles all JWT operations: generating access and refresh tokens, extracting claims, and validating tokens. This service has no state of its own — it doesn't store anything. It signs and verifies.
The design is intentionally functional: every method takes inputs and returns outputs without side effects. This makes JwtService trivially testable without mocks — you can verify token generation and validation with straightforward JUnit tests using real keys.
A note on the extractAllClaims() method: this is where signature verification happens. If the signature doesn't match the key, JJWT throws a SignatureException. If the token is expired, it throws ExpiredJwtException. If the token is malformed (not three Base64 parts), it throws MalformedJwtException. All of these are caught upstream in the filter — JwtService lets them propagate and the filter handles them cleanly.
package io.thecodeforge.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; import java.security.Key; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.function.Function; @Service public class JwtService { @Value("${application.security.jwt.secret-key}") private String secretKey; @Value("${application.security.jwt.expiration}") private long jwtExpiration; @Value("${application.security.jwt.refresh-token.expiration}") private long refreshExpiration; // Generate a standard access token with no extra claims public String generateToken(UserDetails userDetails) { return buildToken(new HashMap<>(), userDetails, jwtExpiration); } // Generate a refresh token — longer TTL, same signing key, no extra claims public String generateRefreshToken(UserDetails userDetails) { return buildToken(new HashMap<>(), userDetails, refreshExpiration); } // Generate an access token with application-specific extra claims (roles, tenant_id, etc.) public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) { return buildToken(extraClaims, userDetails, jwtExpiration); } private String buildToken( Map<String, Object> extraClaims, UserDetails userDetails, long expiration ) { return Jwts.builder() .setClaims(extraClaims) // custom claims first — setSubject overwrites sub if set in claims .setSubject(userDetails.getUsername()) // email is the subject .setIssuer("thecodeforge-auth") .setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(new Date(System.currentTimeMillis() + expiration)) .signWith(getSignInKey(), SignatureAlgorithm.HS256) .compact(); } // Called by the filter on every authenticated request public boolean isTokenValid(String token, UserDetails userDetails) { final String username = extractUsername(token); return username.equals(userDetails.getUsername()) && !isTokenExpired(token); } public boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); } public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } public Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } // Generic claim extractor — pass any Claims method reference public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } private Claims extractAllClaims(String token) { // This is where signature verification happens // Throws SignatureException, ExpiredJwtException, MalformedJwtException on failure return Jwts.parserBuilder() .setSigningKey(getSignInKey()) .build() .parseClaimsJws(token) .getBody(); } private Key getSignInKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); // Keys.hmacShaKeyFor throws WeakKeyException if keyBytes.length < 32 return Keys.hmacShaKeyFor(keyBytes); } }
generate Token() -> HS256, 15-minute TTL
generateRefreshToken() -> HS256, 7-day TTL
isTokenValid() -> verifies signature + expiration on every call
JwtAuthenticationFilter: Intercepting Every Request
The JwtAuthenticationFilter extends OncePerRequestFilter — it runs exactly once per HTTP request, guaranteed, before the request reaches any controller. Its job is straightforward: extract the JWT from the Authorization header, validate it, load the user, and set the SecurityContextHolder so Spring Security knows who this request belongs to.
The 'once per request' guarantee matters because Spring's filter chain can call filters multiple times in forward or include scenarios. OncePerRequestFilter prevents duplicate authentication — without it, you'd be doing redundant database lookups and JWT validations on the same request.
The most important behavioral contract: the filter does not reject requests. It either sets the SecurityContext (for valid tokens) or it doesn't (for missing, expired, or invalid tokens). The authorization decision — whether a given endpoint requires authentication and what role it requires — is made entirely by Spring Security's authorization rules in SecurityConfig. The filter just hands Spring Security the authenticated principal when one exists.
package io.thecodeforge.security; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Slf4j @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal( @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain ) throws ServletException, IOException { final String authHeader = request.getHeader("Authorization"); // No token present — skip silently and let Spring Security handle authorization // This is the CORRECT behavior for public endpoints like /auth/login if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } final String jwt = authHeader.substring(7); // strip "Bearer " prefix final String username; try { username = jwtService.extractUsername(jwt); } catch (Exception e) { // Token is expired, malformed, or has an invalid signature // Log at debug level — this is normal for expired tokens, not an error log.debug("JWT extraction failed for request {}: {}", request.getRequestURI(), e.getMessage()); filterChain.doFilter(request, response); return; } // Only authenticate if we have a username and no authentication is set yet // The null check prevents re-authenticating an already-authenticated request if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtService.isTokenValid(jwt, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, // credentials null — we use the JWT, not a password userDetails.getAuthorities() // roles from UserDetails, loaded from database ); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); log.debug("Authenticated user '{}' for request {}", username, request.getRequestURI()); } } filterChain.doFilter(request, response); } }
Every request with 'Authorization: Bearer <token>' is authenticated.
Requests without Authorization header pass through — Spring Security handles the 401.
- Extract token from Authorization: Bearer <token> header — return early without setting context if header is absent
- Parse the token to extract the username — catch all exceptions and return early if parsing fails (expired, malformed, bad signature)
- Load UserDetails from database via UserDetailsService — this is the only database call per request in the JWT flow
- Set SecurityContextHolder with the authenticated user — Spring Security uses this for every subsequent authorization decision
- Never throw exceptions for missing or invalid tokens — not even a RuntimeException — the filter must always call filterChain.doFilter() to pass control forward
SecurityConfig: The Filter Chain Configuration
SecurityConfig is the control plane for Spring Security — it defines the filter chain order, which endpoints are public, session management policy, and exception handling. In Spring Security 6, this is a SecurityFilterChain bean returned from a @Bean method. WebSecurityConfigurerAdapter is removed — do not try to extend it.
The order of configuration in the lambda matters. CSRF is disabled first because stateless APIs don't use cookies for authentication (the CSRF attack vector requires cookie-based auth). Session management is set to STATELESS so no JSESSIONID cookie is ever created. The JwtAuthenticationFilter is inserted before UsernamePasswordAuthenticationFilter — this is what makes JWT tokens take precedence over form-based login.
The @EnableMethodSecurity annotation on the class enables @PreAuthorize processing. Without it, every @PreAuthorize annotation in your controllers is silently ignored with no log output — a subtle misconfiguration that can leave admin endpoints open to any authenticated user.
package io.thecodeforge.security; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @EnableMethodSecurity // Required for @PreAuthorize — without this, annotations are silently ignored @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider; private final JwtAuthenticationEntryPoint authEntryPoint; private final JwtAccessDeniedHandler accessDeniedHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // Disable CSRF — stateless APIs use Authorization headers, not cookies // CSRF attacks target cookie-based auth; JWT via header is not vulnerable .csrf(AbstractHttpConfigurer::disable) // Authorization rules — order matters, more specific rules first .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/auth/**").permitAll() // login, register, refresh .requestMatchers("/actuator/health").permitAll() // health check for load balancers .anyRequest().authenticated() // everything else requires JWT ) // Never create a HttpSession — JWT is the only auth mechanism .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) // Custom exception handlers — JSON responses instead of Spring's HTML error pages .exceptionHandling(ex -> ex .authenticationEntryPoint(authEntryPoint) // 401 handler .accessDeniedHandler(accessDeniedHandler) // 403 handler ) .authenticationProvider(authenticationProvider) // JwtAuthenticationFilter runs before UsernamePasswordAuthenticationFilter // This ensures JWT tokens are processed before any form-login logic .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }
Public: /api/v1/auth/**, /actuator/health
Protected: all other endpoints require valid JWT
Sessions: STATELESS — no JSESSIONID cookie created
Method security: @PreAuthorize enabled
AuthenticationController: Register, Login, and Refresh Endpoints
The AuthenticationController exposes the three endpoints that bootstrap every JWT auth session: register (create a new user account), login (authenticate and issue tokens), and refresh (exchange a valid refresh token for a new access token). All three live under /api/v1/auth/** which is in the permitAll() list — they must work without a pre-existing JWT.
The login flow: client sends credentials → AuthenticationManager delegates to DaoAuthenticationProvider → user is loaded via UserDetailsService → BCrypt verifies the password → on success, JwtService generates access + refresh tokens → both are returned in the response.
Error handling deserves attention. On login failure, return a generic 'Invalid email or password' for both wrong email and wrong password cases. Returning 'User not found' for unknown emails and 'Wrong password' for known emails is user enumeration — attackers can use it to harvest valid email addresses at scale before launching targeted attacks.
The refresh flow intentionally issues only a new access token — not a new refresh token. Rotating the refresh token on every use (making it single-use) is more secure but adds complexity. Single-use refresh tokens require handling the race condition where a client makes two concurrent requests both triggering a refresh. If you need that security level, add it as a deliberate feature with proper distributed locking — don't half-implement it.
package io.thecodeforge.security; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor public class AuthenticationController { private final AuthenticationManager authenticationManager; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; @PostMapping("/register") public ResponseEntity<AuthenticationResponse> register(@Valid @RequestBody RegisterRequest request) { // Check for duplicate email before creating — return 400 with a clear message if (userRepository.findByEmail(request.getEmail()).isPresent()) { return ResponseEntity.badRequest().body( AuthenticationResponse.builder().error("Email already registered").build() ); } User user = User.builder() .email(request.getEmail()) .firstname(request.getFirstname()) .lastname(request.getLastname()) .password(passwordEncoder.encode(request.getPassword())) // BCrypt hash .role(Role.USER) // new users are USER by default — admins are promoted separately .build(); userRepository.save(user); return ResponseEntity.ok(AuthenticationResponse.builder() .accessToken(jwtService.generateToken(user)) .refreshToken(jwtService.generateRefreshToken(user)) .build()); } @PostMapping("/login") public ResponseEntity<AuthenticationResponse> login(@Valid @RequestBody AuthenticationRequest request) { try { // authenticate() throws BadCredentialsException if email or password is wrong authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()) ); User user = userRepository.findByEmail(request.getEmail()).orElseThrow(); return ResponseEntity.ok(AuthenticationResponse.builder() .accessToken(jwtService.generateToken(user)) .refreshToken(jwtService.generateRefreshToken(user)) .build()); } catch (BadCredentialsException e) { // Generic message — never reveal which field was wrong // 'User not found' vs 'Wrong password' enables email enumeration attacks return ResponseEntity.status(401).body( AuthenticationResponse.builder().error("Invalid email or password").build() ); } } @PostMapping("/refresh") public ResponseEntity<AuthenticationResponse> refresh(@RequestBody Map<String, String> request) { String refreshToken = request.get("refreshToken"); if (refreshToken == null || refreshToken.isBlank()) { return ResponseEntity.badRequest().body( AuthenticationResponse.builder().error("Refresh token required").build() ); } try { String username = jwtService.extractUsername(refreshToken); User user = userRepository.findByEmail(username).orElseThrow(); if (jwtService.isTokenValid(refreshToken, user)) { // Issue a new access token — refresh token is not rotated in this implementation return ResponseEntity.ok(AuthenticationResponse.builder() .accessToken(jwtService.generateToken(user)) .build()); } return ResponseEntity.status(401).body( AuthenticationResponse.builder().error("Refresh token is invalid or expired").build() ); } catch (Exception e) { return ResponseEntity.status(401).body( AuthenticationResponse.builder().error("Refresh token is invalid or expired").build() ); } } }
POST /api/v1/auth/login -> 200 {accessToken, refreshToken} | 401 {error}
POST /api/v1/auth/refresh -> 200 {accessToken} | 401 {error}
authenticate() call delegates entirely to DaoAuthenticationProvider. It calls loadUserByUsername(), gets the stored BCrypt hash, runs BCrypt.checkpw(submittedPassword, storedHash), and throws BadCredentialsException if anything is wrong. You don't need to write any password comparison logic. The AuthenticationManager bean comes from AuthenticationConfiguration — it's wired up automatically from the AuthenticationProvider you defined in ApplicationConfig.Role-Based Access Control: Method and URL Security
Authentication answers 'who are you?' — authorization answers 'what are you allowed to do?' JWT auth in Spring Boot supports two authorization approaches: URL-based (in SecurityConfig) and method-based (@PreAuthorize on controller methods). Both have their place and they're complementary, not competing.
URL-based authorization is coarse-grained: /admin/ requires ROLE_ADMIN, /api/ requires any authenticated user. It's the outer security boundary — easy to see, easy to audit, enforced before the request reaches any controller code.
Method-based authorization is fine-grained: this specific method requires ROLE_ADMIN, this other method requires ROLE_USER or ROLE_ADMIN, this endpoint requires that the requesting user is the owner of the resource. @PreAuthorize with SpEL (Spring Expression Language) gives you the expressiveness to enforce ownership: @PreAuthorize("hasAuthority('ROLE_ADMIN') or #userId == authentication.principal.id").
Use both together: URL-based for broad security boundaries, method-based for business logic rules. The security boundary catches misconfiguration at the infrastructure level. The method-level annotation ensures the business rule is enforced even if the URL pattern changes.
package io.thecodeforge.controller; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/v1") public class AuthorizationDemoController { // Any authenticated user — no role requirement @GetMapping("/profile") public ResponseEntity<Map<String, String>> profile(Authentication auth) { return ResponseEntity.ok(Map.of( "username", auth.getName(), "authorities", auth.getAuthorities().toString() )); } // Admin only — hasAuthority uses the exact string, no ROLE_ prefix added automatically @GetMapping("/admin/dashboard") @PreAuthorize("hasAuthority('ROLE_ADMIN')") public ResponseEntity<Map<String, String>> adminDashboard() { return ResponseEntity.ok(Map.of("message", "Admin dashboard")); } // Multiple roles accepted — either ROLE_USER or ROLE_ADMIN can access @GetMapping("/orders") @PreAuthorize("hasAnyAuthority('ROLE_USER', 'ROLE_ADMIN')") public ResponseEntity<Map<String, String>> orders() { return ResponseEntity.ok(Map.of("message", "Your orders")); } // Ownership check — user can access their own data, admin can access any // #userId binds the @PathVariable value into the SpEL expression @GetMapping("/users/{userId}/data") @PreAuthorize("hasAuthority('ROLE_ADMIN') or #userId == authentication.principal.id") public ResponseEntity<Map<String, String>> userData( @PathVariable Integer userId, Authentication auth ) { return ResponseEntity.ok(Map.of( "requestedUser", String.valueOf(userId), "requestingUser", auth.getName() )); } // Combining method-level and URL-level security is intentional // URL level: /api/v1/** -> any authenticated user // Method level: hasAuthority('ROLE_ADMIN') -> further restricted @DeleteMapping("/admin/users/{userId}") @PreAuthorize("hasAuthority('ROLE_ADMIN')") public ResponseEntity<Void> deleteUser(@PathVariable Integer userId) { // Admin-only destructive operation return ResponseEntity.noContent().build(); } }
GET /api/v1/admin/dashboard -> 200 (ROLE_ADMIN) | 403 (ROLE_USER)
GET /api/v1/orders -> 200 (ROLE_USER or ROLE_ADMIN)
GET /api/v1/users/123/data -> 200 (ROLE_ADMIN or user id=123) | 403 (other users)
DELETE /api/v1/admin/users/456 -> 204 (ROLE_ADMIN) | 403 (ROLE_USER)
Exception Handling: 401 and 403 Responses
By default, Spring Security returns HTML error pages or redirects to /login when authentication fails. For a REST API serving JSON, this is completely wrong — mobile apps, SPAs, and API clients expect JSON with a meaningful status code. Returning HTML to a client expecting JSON causes silent failures: the client tries to parse the HTML as JSON, gets a parse error, and usually displays a blank screen or a generic 'something went wrong' message.
Two Spring Security interfaces handle this cleanly: AuthenticationEntryPoint (called when authentication is required but absent — the correct response is 401 Unauthorized) and AccessDeniedHandler (called when the user is authenticated but lacks the required role — the correct response is 403 Forbidden). The distinction matters to API clients: 401 means 'send credentials', 403 means 'you are authenticated but not allowed'.
Register both handlers in SecurityConfig using http.exceptionHandling(). Without this registration, Spring Security falls back to its defaults — HTML error pages for both cases.
package io.thecodeforge.security; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import java.io.IOException; import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; @Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void commence( HttpServletRequest request, HttpServletResponse response, AuthenticationException authException ) throws IOException { response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // Ordered map so the JSON output has a consistent field order Map<String, Object> body = new LinkedHashMap<>(); body.put("status", 401); body.put("error", "Unauthorized"); body.put("message", "Authentication required. Provide a valid JWT in the Authorization header."); body.put("path", request.getRequestURI()); body.put("timestamp", Instant.now().toString()); objectMapper.writeValue(response.getOutputStream(), body); } }
Content-Type: application/json
{
"status": 401,
"error": "Unauthorized",
"message": "Authentication required. Provide a valid JWT in the Authorization header.",
"path": "/api/v1/profile",
"timestamp": "2026-04-18T14:23:00Z"
}
Token Storage on the Client: HttpOnly Cookie vs LocalStorage
Where you store the JWT on the client determines the threat surface for token theft. This isn't a performance decision or a convenience decision — it's a security architecture decision with real consequences.
localStorage — Available via document.localStorage in any JavaScript context on the page. Simple to implement, survives page refreshes, works well in frameworks that use interceptors. The critical weakness: any XSS vulnerability on the page — in your code, in a third-party library, in an analytics script, in an ad — gives the attacker access to document.localStorage.getItem('token'). XSS is extremely common. localStorage tokens are extremely easy to steal from it.
HttpOnly Cookie — The browser stores and sends the cookie automatically, but JavaScript cannot read it. document.cookie does not include HttpOnly cookies. XSS attacks that access localStorage cannot access HttpOnly cookies. The trade-off: you need SameSite=Strict or a CSRF token to prevent cross-origin cookie submission.
In-Memory (JavaScript variable) — Stored in a variable in your application's JavaScript state. Cannot be accessed from outside the page's execution context. Survives navigation within the SPA. Lost on page refresh. The refresh token in an HttpOnly cookie can recover it.
Production pattern: access token in memory (short-lived, lost on refresh, recovered by refresh token), refresh token in HttpOnly + Secure + SameSite=Strict cookie. This combines XSS protection for the long-lived credential with acceptable UX.
package io.thecodeforge.security; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Service; import java.util.Arrays; import java.util.Optional; @Service public class CookieTokenService { private static final String REFRESH_TOKEN_COOKIE = "refresh_token"; private static final String REFRESH_TOKEN_PATH = "/api/v1/auth/refresh"; private static final int SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60; public void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) { Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE, refreshToken); cookie.setHttpOnly(true); // JavaScript cannot access this cookie cookie.setSecure(true); // HTTPS only — never sent over HTTP cookie.setAttribute("SameSite", "Strict"); // not sent on cross-origin requests — CSRF protection cookie.setPath(REFRESH_TOKEN_PATH); // scoped to refresh endpoint only — not sent with every request cookie.setMaxAge(SEVEN_DAYS_SECONDS); // matches refresh token TTL in JwtService response.addCookie(cookie); } public Optional<String> extractRefreshTokenFromCookie(HttpServletRequest request) { if (request.getCookies() == null) { return Optional.empty(); } return Arrays.stream(request.getCookies()) .filter(c -> REFRESH_TOKEN_COOKIE.equals(c.getName())) .map(Cookie::getValue) .findFirst(); } public void clearRefreshTokenCookie(HttpServletResponse response) { Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE, ""); cookie.setHttpOnly(true); cookie.setSecure(true); cookie.setAttribute("SameSite", "Strict"); cookie.setPath(REFRESH_TOKEN_PATH); cookie.setMaxAge(0); // tells the browser to delete the cookie immediately response.addCookie(cookie); } }
- localStorage is accessible to any JavaScript on the page — one XSS vulnerability steals every token for every user who has loaded the page
- HttpOnly cookies cannot be read by JavaScript — XSS attacks that steal from localStorage can't steal HttpOnly cookies
- SameSite=Strict prevents the refresh cookie from being sent on any cross-origin request — CSRF protection without CSRF tokens
- Path=/api/v1/auth/refresh scopes the cookie to the refresh endpoint only — the cookie is not sent with every API call, reducing exposure
- Access token in memory means it's lost on page refresh — the refresh token in the HttpOnly cookie recovers it transparently
Production Security Checklist
After implementing JWT auth, run through this checklist before every deployment. Every item on this list corresponds to a real production incident I've either experienced or debugged for someone else.
- Secret key is at least 256 bits (32 bytes decoded) and stored in an environment variable or secrets manager — not in source code.
- Session management is set to STATELESS — no JSESSIONID cookie is ever created.
- CSRF is disabled for the stateless API.
- Access token TTL is 15-60 minutes.
- Refresh token TTL is 7-30 days, longer than the access token.
- Passwords are hashed with BCrypt work factor 10 or higher.
- The JWT payload contains no passwords, API keys, credit card data, SSNs, or other PII.
- Exception handlers (AuthenticationEntryPoint, AccessDeniedHandler) return JSON, not HTML.
- @EnableMethodSecurity is present on SecurityConfig if you use @PreAuthorize.
- Rate limiting is configured on /auth/login — JWT is stateless so there is no built-in lockout after N failures.
- Unique signing keys are configured per environment — staging tokens must not work in production.
- A startup health check validates the key length and format before the app accepts traffic.
package io.thecodeforge.health; import io.jsonwebtoken.io.Decoders; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; /** * Validates JWT configuration at startup. * Exposed via GET /actuator/health — load balancers use this to gate traffic. * A misconfigured JWT key is detected before the first user tries to log in. */ @Component public class JwtSecurityHealthIndicator implements HealthIndicator { // Known default/test keys that must never reach production private static final String DEV_FALLBACK_PREFIX = "dGhpcyBpcyBhIHZlcnkgc2VjdXJlIHNlY3JldCBrZXkgZm9yIGp3dA"; @Value("${application.security.jwt.secret-key}") private String secretKey; @Value("${application.security.jwt.expiration}") private long accessTokenTtl; @Value("${application.security.jwt.refresh-token.expiration}") private long refreshTokenTtl; @Override public Health health() { try { if (secretKey == null || secretKey.isBlank()) { return Health.down() .withDetail("error", "JWT secret key is not configured") .build(); } if (secretKey.startsWith(DEV_FALLBACK_PREFIX)) { return Health.down() .withDetail("error", "JWT secret key is the development fallback — set JWT_SECRET environment variable") .build(); } byte[] keyBytes = Decoders.BASE64.decode(secretKey); if (keyBytes.length < 32) { return Health.down() .withDetail("error", "JWT secret key is too short for HS256") .withDetail("required_bits", 256) .withDetail("actual_bits", keyBytes.length * 8) .build(); } if (accessTokenTtl < 300_000) { // less than 5 minutes return Health.down() .withDetail("error", "Access token TTL is too short (< 5 minutes)") .build(); } if (accessTokenTtl > 86_400_000) { // more than 24 hours return Health.down() .withDetail("error", "Access token TTL is too long (> 24 hours) — use a refresh token instead") .build(); } if (refreshTokenTtl <= accessTokenTtl) { return Health.down() .withDetail("error", "Refresh token TTL must be longer than access token TTL") .build(); } return Health.up() .withDetail("key_bits", keyBytes.length * 8) .withDetail("access_token_ttl_minutes", accessTokenTtl / 60_000) .withDetail("refresh_token_ttl_days", refreshTokenTtl / 86_400_000) .build(); } catch (Exception e) { return Health.down() .withDetail("error", "JWT configuration is invalid: " + e.getMessage()) .build(); } } }
{
"status": "UP",
"components": {
"jwtSecurityHealth": {
"status": "UP",
"details": {
"key_bits": 256,
"access_token_ttl_minutes": 15,
"refresh_token_ttl_days": 7
}
}
}
}
| Aspect | Traditional Session (Stateful) | JWT (Stateless) |
|---|---|---|
| Server Memory | High — stores session objects for every active user in RAM or Redis | Zero — no session state stored server-side; validation is purely cryptographic |
| Scalability | Hard — requires session replication or sticky sessions across instances | Easy — any instance validates the token independently with no shared state |
| Revocation | Instant — delete the session from Redis and the user is out immediately | Complex — requires token blacklisting or waiting for the short TTL to expire |
| Cross-Domain | Difficult — cookies are restricted by same-origin policy by default | Simple — the Authorization header carries the token across any domain |
| CSRF Protection | Required — cookies are sent automatically, so CSRF tokens or SameSite headers are needed | Not applicable — Authorization headers are not sent automatically by browsers |
| Token Size | Small — just a session ID (e.g., 32 hex chars in the cookie) | Larger — full Base64URL token with header, payload, signature (200-500 chars typical) |
| Microservices | Requires a shared session store (Redis) accessible from every service | Each service validates independently — no shared infrastructure required |
| Mobile Apps | Requires cookie management or custom header forwarding | Native fit — Authorization header is the standard for mobile HTTP clients |
🎯 Key Takeaways
- JWT enables stateless authentication by moving session state from the server to the client — any server can validate the token without a shared session store.
- A JWT has three parts: Header (algorithm), Payload (claims), and Signature (cryptographic proof). The payload is Base64-encoded, not encrypted — never put sensitive data in it.
- Use HS256 for single-service architectures (one shared secret). Use RS256 for microservices (private key signs, public key verifies — resource servers can't forge tokens).
- Access tokens should be short-lived (15-60 minutes) to limit stolen token damage. Refresh tokens should be long-lived (7-30 days) to avoid forcing re-authentication.
- Spring Security 6 requires SessionCreationPolicy.STATELESS and the JwtAuthenticationFilter added before UsernamePasswordAuthenticationFilter in the filter chain.
- The JwtAuthenticationFilter should silently skip requests without tokens — don't throw exceptions. Spring Security's authorization rules handle the 401/403 responses.
- Store refresh tokens in HttpOnly, Secure, SameSite=Strict cookies. Store access tokens in memory (JavaScript variable) — not localStorage.
- Create a SecurityHealthIndicator that validates the JWT secret key length and format at startup — fail fast, not on the first user login.
- Use @PreAuthorize for method-level RBAC and .requestMatchers().hasAuthority() for URL-level RBAC. Both require @EnableMethodSecurity on the config class.
- Register AuthenticationEntryPoint (401) and AccessDeniedHandler (403) to return clean JSON error responses instead of Spring's default HTML error pages.
- Rate-limit the /auth/login endpoint to prevent brute-force attacks. JWT auth is stateless, so there's no server-side lockout after N failed attempts.
- Never hardcode the JWT secret key. Use environment variables (${JWT_SECRET}), Spring Cloud Config, or a secrets manager. A key in source code is a key in git history.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QDescribe the 3 components of a JWT (Header, Payload, Signature). How is the signature generated, and what happens if someone modifies the payload?JuniorReveal
- QExplain the 'Stateless' nature of JWT. If the server doesn't store anything, how do you revoke a user's access before the token expires? Describe at least two strategies.Mid-levelReveal
- QWhat is the difference between HS256 and RS256? When would you choose one over the other in a microservices architecture?Mid-levelReveal
- QWalk me through the complete JWT authentication flow in Spring Boot: from the user clicking 'Login' to the client making an authenticated API call.Mid-levelReveal
- QWhere should a JWT be stored on the client-side for maximum security? Compare localStorage, sessionStorage, and HttpOnly cookies.Mid-levelReveal
- QHow does the JwtAuthenticationFilter work? Why does it extend OncePerRequestFilter, and what happens if the token is missing or expired?SeniorReveal
- QWhat is the purpose of the refresh token? Why not just use a long-lived access token?SeniorReveal
- QWhy is SessionCreationPolicy.STATELESS necessary when implementing JWT in Spring Security?Mid-levelReveal
- QHow would you implement role-based access control (RBAC) with JWT in Spring Boot?SeniorReveal
- QYour JWT secret key has been compromised. Walk me through your incident response.SeniorReveal
Frequently Asked Questions
Is JWT more secure than Session cookies?
Neither is inherently more secure — they solve different problems. Sessions are easier to revoke (just delete the session on the server) but harder to scale (requires a shared session store). JWTs are easier to scale (stateless validation) but harder to revoke (requires blacklisting or short TTLs). For security: sessions with HttpOnly cookies are protected from XSS but need CSRF protection. JWTs stored in HttpOnly cookies get the same XSS protection. The real security difference: with JWT, if a token is stolen, it's valid until it expires. With sessions, you can kill the session instantly.
What happens if my JWT secret key is stolen?
An attacker with your secret key can forge tokens for any user — including admin users. This is a critical security incident. Immediate response: (1) rotate the secret key, (2) all existing tokens become invalid, (3) all users must re-authenticate. To avoid a mass logout, implement key versioning: accept tokens signed with either the old or new key during a transition window, then remove the old key after all old tokens have expired.
Can I use JWT for a traditional monolithic web application?
You can, but it's often overkill. In a monolith, a single server handles all requests — there's no 'sticky session' problem because there's only one session store. Traditional server-side sessions are simpler to implement, easier to revoke, and don't require client-side token management. JWT shines in microservices (multiple servers validating tokens independently) and SPAs (decoupled frontend and backend).
What is the difference between an access token and a refresh token?
An access token is short-lived (15-60 minutes) and sent with every API request in the Authorization header. A refresh token is long-lived (7-30 days) and sent only to the /auth/refresh endpoint to get a new access token. The separation limits damage: if an access token is stolen, it's only valid for 15 minutes. If a refresh token is stolen, the attacker can get new access tokens — but the refresh endpoint can enforce additional checks.
How do I revoke a JWT before it expires?
JWTs are stateless — there's no server-side session to delete. Revocation strategies: (1) short TTLs (15 minutes) — just wait for expiration, (2) token blacklist — store revoked jti values in Redis and check in the filter, (3) refresh token revocation — revoke the refresh token server-side, (4) key rotation — rotate the signing key, invalidating all tokens. For most applications, short TTLs + refresh token revocation is sufficient.
How do I test JWT authentication in my integration tests?
Use MockMvc with a test JWT. Generate a token using your JwtService with a test user, then pass it in the Authorization header of your MockMvc request. For unit tests, mock the JwtService. For integration tests, use @SpringBootTest with a test database (H2). Example: mockMvc.perform(get('/api/v1/profile').header('Authorization', 'Bearer ' + testToken)).andExpect(status().isOk()).
Why does Spring Security return 403 instead of 401?
In Spring Security, 401 means 'not authenticated' (no valid credentials provided) and 403 means 'authenticated but not authorized' (valid credentials but insufficient permissions). If you're getting 403 on a protected endpoint, the user is authenticated but lacks the required role. If you're getting 403 on a public endpoint, check that the endpoint is in your permitAll() list.
How do I handle token expiration gracefully on the client side?
The standard pattern: (1) make an API call with the access token, (2) if you get 401, call /auth/refresh with the refresh token, (3) if refresh succeeds, retry the original request with the new access token, (4) if refresh fails, redirect to login. Implement this as an Axios/OkHttp interceptor. Handle the race condition where multiple concurrent requests all get 401 — queue them, refresh once, then retry all with the new token.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.