JWT Secret in Git: 10,000 Forged Admin Requests Per Hour
A single JWT secret in git let attackers forge admin tokens, generating 10,000 API calls hourly.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
Most teams slap JWT logic into a filter without understanding what they're wiring up. The JwtAuthenticationFilter is a OncePerRequestFilter. That's not just a Spring convention — it's a contract that guarantees your filter runs exactly once per request, even if the request gets forwarded internally. Forwarding happens more than you think, and duplicate authentication checks destroy latency.
Your filter checks three things: the Authorization header, token validity, and whether the principal is already authenticated. If any condition fails, you bail early. No database calls, no heavy processing. This filter sits right before the security context is built, so it's the perfect choke point for token validation.
But here's the trap: if you load the full user from the database inside this filter, you've just killed your performance. Only load what you need — the username and roles — from the token claims.
The filter doesn't authenticate the user. That's SecurityContextHolder's job. Your filter just extracts the token, validates it, and passes the authentication token upstream. Keep it lean or your production latency will hate you.
Think of JWT Authentication with Spring Boot as a digital 'All-Access Pass' for a music festival. Instead of the security guard (the server) checking a massive guest list (the database) every time you want to enter a new stage (an API endpoint), they give you a tamper-proof wristband (the JWT) once you show your ID. As long as you have that wristband, you can move around freely without the guard needing to remember who you are. The wristband contains your permissions and an expiration time, all sealed with a special holographic stamp that can't be forged.
The critical detail: the holographic stamp (the JWT signature) is made using a secret only the festival knows. If you try to alter the wristband — change your VIP status, extend the expiration — the stamp no longer matches, and the guard rejects it. The guard never needs to call the festival office to verify your wristband. That's the 'stateless' part — no database lookup, no session store, no shared memory between servers.
One thing people miss the first time: the wristband isn't sealed in an opaque envelope. Anyone who finds your wristband can read what's printed on it — your name, your access level, when it expires. The holographic stamp only proves the wristband hasn't been altered — it doesn't hide what's written on it. This is why you never print sensitive information on the wristband itself.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
Why JWT Authentication in Spring Boot Is Not a Library Problem
JWT authentication in Spring Boot is a stateless, token-based mechanism where a server issues a signed JSON token after credential validation, and the client sends that token with every request. The server never stores session state — it verifies the token's signature and expiration on each call. This shifts trust from server-side memory to the cryptographic integrity of the token itself.
The core mechanic is simple: the server signs a payload (subject, roles, expiration) with a secret key using HMAC-SHA256 or an RSA private key. Every subsequent request carries the token in an Authorization header. Spring Security's filter chain intercepts the request, extracts the token, validates the signature, and populates the SecurityContext. No database lookup, no session store — O(1) verification per request.
Use JWT when you need horizontal scalability without shared session state, or when delegating authentication to a separate service (API gateway, auth server). It's not a replacement for sessions in every app — it's a tool for distributed systems where latency and statelessness matter more than the ability to revoke individual tokens instantly.
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()); } } }
- 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: 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
HS256 vs RS256 Comparison Table
Below is a side-by-side comparison of the two most common JWT signing algorithms. This table summarizes the key differences in terms of key management, microservice suitability, revocation, and setup complexity.
| Aspect | HS256 (HMAC-SHA256) | RS256 (RSA-SHA256) |
|---|---|---|
| Key Type | Shared secret (symmetric) – one key does both signing and verification | Private/public key pair (asymmetric) – private key signs, public key verifies |
| Microservice Suitability | Poor – every service that validates needs the same secret; a compromised service can forge tokens for the entire system | Excellent – only the auth service holds the private key; resource services hold a public key that cannot forge tokens |
| Revocation | Difficult – rotating the secret invalidates all tokens across all services simultaneously; requires careful coordination | Easier – rotate the private key on the auth server; resource servers only need to be updated with the new public key (if JWKS, this happens automatically) |
| Setup Complexity | Low – generate a single key (32+ bytes), share it among all services | Moderate – generate a key pair, distribute public key to resource servers (or host a JWKS endpoint) |
| Token Size | Smaller (~211 chars for typical claims) | Larger (~489 chars for typical claims due to RSA signature) |
| Performance | Faster signing and verification (symmetric crypto is computationally cheaper) | Slower signing and verification (asymmetric crypto is more expensive, but still under 1ms for a 2048-bit key) |
| Security Model | Key must be kept secret from all clients; any service that has the key can sign tokens | Private key must be kept secret; public key can be shared openly without compromising signing capability |
Bottom Line: Use HS256 only if you have a single service that both issues and validates tokens. For any multi-service architecture, use RS256 (or ES256) to limit the blast radius of a compromise.
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>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: INFOUser 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); } }
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); } }
- 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(); } }
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() ); } } }
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(); } }
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); } }
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
Access Token vs Refresh Token Comparison Table
Understanding the differences between access tokens and refresh tokens is crucial for designing a secure and user-friendly authentication system. This table highlights the key distinctions.
| Aspect | Access Token | Refresh Token |
|---|---|---|
| TTL (Time-to-Live) | Short-lived: 15–60 minutes | Long-lived: 7–30 days |
| Scope | Sent with every API call in the Authorization header | Only sent to the /auth/refresh endpoint to obtain a new access token |
| Storage (Client) | In-memory (JavaScript variable) — not persisted | HttpOnly + Secure + SameSite=Strict cookie (or secure storage in mobile apps) |
| Storage (Server) | Not stored — stateless (validated cryptographically) | Stored in database or persisted securely for revocation purposes |
| Revocation | Cannot be revoked individually once issued (short TTL makes this acceptable) | Can be revoked server-side by deleting or marking the record as invalid |
| Damage if Stolen | Limited to TTL (minutes) | Can be used to generate new access tokens until revoked or expired |
| Rotation | Not rotated — re-issued via refresh flow | Optionally rotated (single-use) for higher security; new refresh token issued on each use |
| Format | JWT (with signature and claims) | JWT (with signature and claims) or opaque string |
Best Practice: Use a short access token TTL (15 min) and a longer refresh token TTL (7 days). Store the access token in memory and the refresh token in an HttpOnly cookie. Implement refresh token rotation for sensitive applications.
JWT Security Checklist
A concise checklist to ensure your JWT implementation is secure. Go through these items every time you deploy changes to authentication.
- Key Strength & Storage
- - Secret key is at least 256 bits (32 bytes decoded) for HS256, or use RSA 2048+ / EC P-256 for asymmetric.
- - Key is stored in environment variables or a secrets manager — never in source code or configuration files.
- - Unique keys per environment (dev, staging, prod).
- Token Storage
- - Access token stored in memory (JavaScript variable) — not in localStorage or sessionStorage.
- - Refresh token stored in HttpOnly + Secure + SameSite=Strict cookie, scoped to the refresh endpoint path.
- - Mobile apps use secure storage (Keychain, Keystore) for both tokens.
- Token Expiration
- - Access token TTL: 15–60 minutes.
- - Refresh token TTL: 7–30 days (longer than access token TTL).
- Revocation Strategy
- - Implement refresh token revocation (server-side database or cache).
- - Use short access token TTLs to limit damage window.
- - Optionally use a jti (token ID) blacklist for immediate access token revocation via Redis.
- Rotation Policy
- - Rotate refresh tokens on each use (single-use pattern) for high-security systems.
- - Rotate the signing key periodically (e.g., every 90 days) or immediately after any key compromise.
- - Support key versioning to avoid mass logout during rotation.
- Algorithm Choice
- - Single service: HS256 is acceptable.
- - Multi-service (microservices): RS256 or ES256 with JWKS endpoint.
- - Avoid 'none' algorithm — always verify the algorithm in the JWT header matches the expected value.
- Rate Limiting & Brute Force
- - Rate-limit
/auth/loginendpoint (e.g., 5 attempts per minute per IP). - - Return a generic error message for failed login attempts (no user enumeration).
- Audit & Monitoring
- - Log all token validation failures (expired, invalid signature) at DEBUG level.
- - Monitor for unusual patterns: many 401s from one IP, spikes in refresh token usage.
- - Alert on any use of a blacklisted jti.
- Payload Sensitivity
- - Never include PII, passwords, API keys, or any sensitive data in the JWT payload.
- - If you need encrypted claims, use JWE (JSON Web Encryption) — but prefer keeping secrets out of the token.
- Startup Validation
- - Implement a health check (SecurityHealthIndicator) that validates key length, format, and prevents startup with a known default key.
Token Blacklisting Strategies
Because JWTs are stateless, there is no built-in way to revoke a token before it expires. For applications that require immediate revocation (e.g., password change, account suspension, detected token theft), you need a blacklisting mechanism. Here are the most common strategies, ordered from simplest to most robust.
1. Short TTL (Implicit Blacklisting) The simplest strategy: make access tokens expire quickly (15 minutes). A stolen token is only valid for a short window. Combined with refresh token revocation (see below), this is often sufficient for many applications. No additional infrastructure needed.
2. Redis Blocklist (jti-based) Include a unique token ID (jti claim) in every JWT. Store revoked jti values in Redis with a TTL equal to the remaining token lifetime. On every request, before trusting the token, check if its jti is in Redis. If found, reject the token.
Advantages: Immediate revocation, works with any token type (access or refresh). Disadvantages: Adds a Redis lookup on every request (though fast), requires Redis to be highly available.
3. Database Blocklist Similar to Redis but using a database table. Less performant but simpler to implement if you already use a relational database. Index on jti and expiration for fast lookups. Clean up expired rows periodically.
4. Refresh Token Revocation Store refresh tokens in a database (or use a persistent store). When a refresh token is revoked (e.g., user logs out, password change), mark it as invalid in the database. The access token will expire within its short TTL, and the attacker cannot get a new one because the refresh token is revoked.
5. Key Rotation Rotate the signing key. All tokens signed with the old key become invalid. This is drastic but effective — it revokes every token at once. Use key versioning to accept both old and new keys during a transition window to avoid mass logout.
Recommended Hybrid Approach: - Short access token TTL (15 min) - Refresh token revocation via database - Optional: Redis jti blacklist for additional security if immediate access token revocation is required. - Key rotation for emergency situations (compromised key).
package io.thecodeforge.security; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor public class TokenBlacklistService { private final StringRedisTemplate redisTemplate; private static final String BLACKLIST_PREFIX = "jwt:blacklist:"; // Add a token's jti to the blacklist for the remaining duration of the token public void blacklistToken(String jti, long ttlMillis) { String key = BLACKLIST_PREFIX + jti; redisTemplate.opsForValue().set(key, "revoked", ttlMillis, TimeUnit.MILLISECONDS); } // Check if a token's jti is blacklisted public boolean isTokenBlacklisted(String jti) { return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + jti)); } }
5 Practice Projects to Build Your JWT Skills
Theory is important, but building projects solidifies your understanding. Here are five projects that progressively increase in complexity, each focusing on a different aspect of JWT authentication.
1. Multi-Role JWT API Build a simple REST API with three roles: USER, MODERATOR, ADMIN. Users can view their own profile; moderators can view all users but not delete; admins can manage everything. Implement role-based access control using @PreAuthorize. Test with tokens containing different roles.
2. Refresh Token Rotation Extend the basic JWT API to implement refresh token rotation. Each time a refresh token is used, the old one is invalidated and a new one is issued. Handle concurrent refresh requests — use a database lock or optimistic locking to prevent race conditions where two requests both get valid new tokens.
3. Microservice JWT Propagation Create two microservices: an auth service (issues tokens) and a resource service (validates tokens). Use RS256 signing. The resource service validates tokens using a public key from a JWKS endpoint served by the auth service. Propagate the token from the gateway through the call chain.
4. Token Blacklisting with Redis Implement a token blacklist using Redis as described above. Build a logout endpoint that adds the current token's jti to the blacklist. Verify that blacklisted tokens are rejected on subsequent requests. Consider performance implications and ensure the Redis lookup is fast.
5. Multi-Tenant JWT Authentication Build an API that supports multiple tenants. Each user belongs to a tenant, and the JWT carries a tenant_id claim. Ensure data isolation: a user from tenant A cannot access data from tenant B. Use a custom filter to enforce tenant-scoped queries. Include a tenant_admin role that can manage users within their tenant but not others.
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(); } } }
The Refresh Token Rotation Strategy That Stops Token Theft Cold
Most token theft happens because refresh tokens live too long. Period. If a malicious actor grabs a 30-day refresh token, you're compromised until it expires — or until someone notices the account behaving oddly three weeks later. That's not security, that's denial.
Refresh token rotation means you issue a new refresh token every time one is used. The old one dies instantly. If the attacker uses a stolen token and the legitimate user hits the endpoint next, the old token is already invalid. The legitimate user gets a 401, logs in again, and the window of exposure shrinks from weeks to seconds.
Implement this in your refreshToken endpoint: after validating the incoming refresh token, delete it from your database or blacklist, then issue a fresh access-refresh pair. Add re-use detection — if a stale token ever shows up again, invalidate all refresh tokens for that user immediately. That signals a token-in-hand attack. Your user changes their password, and the attacker is locked out.
// io.thecodeforge — java tutorial @Service public class RefreshTokenRotationService { private final JwtService jwtService; private final TokenRepository tokenRepo; public RefreshTokenRotationService(JwtService jwtService, TokenRepository tokenRepo) { this.jwtService = jwtService; this.tokenRepo = tokenRepo; } public TokenPair rotateRefreshToken(String currentRefreshToken) { StoredToken stored = tokenRepo.findByToken(currentRefreshToken) .orElseThrow(() -> new SecurityException("Token not found or already rotated")); // Re-use detection: if token already consumed, invalidate all user tokens if (stored.isConsumed()) { tokenRepo.invalidateAllForUser(stored.getUserId()); throw new SecurityException("Re-use detected — all tokens invalidated"); } // Mark old token as consumed stored.setConsumed(true); tokenRepo.save(stored); // Issue new pair String newAccess = jwtService.generateAccessToken(stored.getUserId()); String newRefresh = jwtService.generateRefreshToken(stored.getUserId()); tokenRepo.save(new StoredToken(newRefresh, stored.getUserId())); return new TokenPair(newAccess, newRefresh); } }
Why You Need a Token Blacklist, Not Just a Short Expiry
Short expiry on access tokens (5-15 minutes) is table stakes. It's not a security strategy — it's a damage-control tactic. The real gap? Users, admins, and support teams need to kill sessions immediately. Not in 10 minutes. Not in 2 hours. Now.
That means blacklisting. When a user logs out, changes their password, or loses their phone, you need to invalidate tokens before they expire. The naive approach — just delete the refresh token — leaves the access token alive. An attacker with the access token can still do damage for the full expiry window.
Store a denylist of token IDs (JTI claims) in Redis with TTL equal to the token's remaining lifespan. Each request? The filter checks if jti is in the denylist before validating the signature. Redis handles TTL automatically — expired tokens fall off the list without cron jobs. For added paranoia, maintain a user-level token version (in the database or Redis) and include it in your JWT claims. Bumping the version invalidates every outstanding token for that user in one shot.
// io.thecodeforge — java tutorial @Component public class RedisTokenBlacklistFilter extends OncePerRequestFilter { private final StringRedisTemplate redis; private final JwtService jwtService; public RedisTokenBlacklistFilter(StringRedisTemplate redis, JwtService jwtService) { this.redis = redis; this.jwtService = jwtService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { chain.doFilter(request, response); return; } String token = authHeader.substring(7); String jti = jwtService.extractJti(token); Boolean isBlacklisted = redis.hasKey("blacklist:" + jti); if (Boolean.TRUE.equals(isBlacklisted)) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token has been revoked"); return; } chain.doFilter(request, response); } }
What Is JwtFilter? The Gatekeeper That Never Sleeps
Most teams slap JWT logic into a filter without understanding what they're wiring up. The JwtAuthenticationFilter is a OncePerRequestFilter. That's not just a Spring convention — it's a contract that guarantees your filter runs exactly once per request, even if the request gets forwarded internally. Forwarding happens more than you think, and duplicate authentication checks destroy latency.
Your filter checks three things: the Authorization header, token validity, and whether the principal is already authenticated. If any condition fails, you bail early. No database calls, no heavy processing. This filter sits right before the security context is built, so it's the perfect choke point for token validation. But here's the trap: if you load the full user from the database inside this filter, you've just killed your performance. Only load what you need — the username and roles — from the token claims.
The filter doesn't authenticate the user. That's SecurityContextHolder's job. Your filter just extracts the token, validates it, and passes the authentication token upstream. Keep it lean or your production latency will hate you.
// io.thecodeforge — java tutorial @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) { this.jwtService = jwtService; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { final String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } final String jwt = authHeader.substring(7); final String username = jwtService.extractUsername(jwt); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); if (jwtService.isTokenValid(jwt, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } } filterChain.doFilter(request, response); } }
Dependency Injection: JwtUtil Is Dead — Use JwtService the Right Way
Stop creating JwtUtil utility classes with static methods. That's Java 1.4 thinking and it breaks every rule of testability and Spring's DI model. The senior approach is a JwtService class annotated with @Service that encapsulates token creation, parsing, and validation. It receives its dependencies — signing key, expiration config — through constructor injection, not by reading files directly.
Why does this matter? Because a static JwtUtil cannot be mocked in integration tests. You'll end up testing against real tokens, which means your test suite is either slow or brittle. JwtService injected via constructor lets you swap implementations, change signing keys per environment, and unit test with zero Spring context. That's production hygiene, not overengineering.
Your JwtService should never expose raw token strings to the rest of the application. Return typed objects: TokenPair, AccessToken, RefreshToken. This prevents any controller or filter from accidentally misusing the values. The service is the single source of truth for all JWT operations — every other class just calls it. If you need to rotate signing keys, add token versioning, or implement claims validation, you change one class, not ten.
// io.thecodeforge — java tutorial @Service public class JwtService { private final String secretKey; private final long jwtExpiration; private final long refreshExpiration; public JwtService(@Value("${jwt.secret-key}") String secretKey, @Value("${jwt.expiration}") long jwtExpiration, @Value("${jwt.refresh-expiration}") long refreshExpiration) { this.secretKey = secretKey; this.jwtExpiration = jwtExpiration; this.refreshExpiration = refreshExpiration; } public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } public String generateToken(UserDetails userDetails) { return buildToken(new HashMap<>(), userDetails, jwtExpiration); } public String generateRefreshToken(UserDetails userDetails) { return buildToken(new HashMap<>(), userDetails, refreshExpiration); } public boolean isTokenValid(String token, UserDetails userDetails) { final String username = extractUsername(token); return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); } private String buildToken(Map<String, Object> extraClaims, UserDetails userDetails, long expiration) { return Jwts.builder() .setClaims(extraClaims) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(new Date(System.currentTimeMillis() + expiration)) .signWith(getSignInKey(), SignatureAlgorithm.HS256) .compact(); } private boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); } private Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } private Claims extractAllClaims(String token) { return Jwts.parserBuilder() .setSigningKey(getSignInKey()) .build() .parseClaimsJws(token) .getBody(); } private Key getSignInKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); return Keys.hmacShaKeyFor(keyBytes); } }
AuthService: The Orchestrator Behind Login and Registration
Most tutorials jam authentication logic into controllers, creating an unmaintainable mess. AuthService exists to separate HTTP handling from business rules. This single class registers new users, authenticates credentials, and issues token pairs. Why? Because a controller should only map requests to responses—never hash passwords or validate tokens. Start with an interface defining three methods: register, authenticate, and refreshToken. The implementation calls UserDetailsService to load users, PasswordEncoder to verify credentials, and JwtService to generate tokens. On registration, check email uniqueness, encode the password, then persist the user. On login, validate credentials and return access/refresh tokens. On refresh, verify the refresh token rotation and issue a new pair. This keeps your controller lean and testable. The real trap? Controllers that call JwtService directly. AuthService centralizes all authentication logic so a single change (adding MFA, rate limiting) touches one file, not ten.
// io.thecodeforge — java tutorial // AuthService orchestrates registration, login, and token refresh @Service public class AuthService { private final UserRepository userRepo; private final PasswordEncoder encoder; private final JwtService jwtService; public AuthService(UserRepository userRepo,PasswordEncoder encoder,JwtService jwtService){ this.userRepo=userRepo; this.encoder=encoder; this.jwtService=jwtService; } public AuthResponse register(RegisterRequest request){ if(userRepo.findByEmail(request.email()).isPresent()) throw new RuntimeException("Email taken"); User user = new User(request.email(), encoder.encode(request.password()), Role.USER); userRepo.save(user); return jwtService.generateTokenPair(user); } public AuthResponse authenticate(AuthRequest request){ User user = userRepo.findByEmail(request.email()).orElseThrow(()->new RuntimeException("Not found")); if(!encoder.matches(request.password(), user.getPassword())) throw new RuntimeException("Bad credentials"); return jwtService.generateTokenPair(user); } }
JwtTokenProvider: The Token Factory That Never Touches Controllers
JwtTokenProvider is the dedicated class responsible for creating and validating JWTs. It wraps the signing algorithm (HS256 or RS256), manages claims, and enforces expiry rules. Why does it need its own class? Because token logic changes independently of authentication flow. If you upgrade from HS256 to RS256, you only modify this single class. The provider exposes four methods: generateAccessToken, generateRefreshToken, validateToken, and extractClaims. Internally, it uses the io.jsonwebtoken library to build tokens with subject (user ID), issued-at, expiration, and custom claims (roles). Validation checks signature, expiration, and issuer. Never embed this logic in a controller or service—that creates coupling. The hidden danger: storing the signing secret inside the provider class. Always inject the secret from configuration so you can rotate keys without code changes. This separation is why enterprise apps survive 8+ library migrations without rewriting authentication.
// io.thecodeforge — java tutorial // Single responsibility: create and validate tokens @Component public class JwtTokenProvider { @Value("${jwt.secret}") private String secretKey; private final long accessExpiry = 3600000; // 1 hour private final long refreshExpiry = 604800000; // 7 days public String generateAccessToken(String userId, String role) { return Jwts.builder() .setSubject(userId) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + accessExpiry)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); } public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }
CustomUserDetailsService: Why You Must Never Use Spring's Default User Lookup
Spring Security's default UserDetailsService expects users stored in memory or a hardcoded map—useless for any real app. CustomUserDetailsService bridges your JPA User entity with Spring's authentication system. It implements the UserDetailsService interface and overrides loadUserByUsername. This method queries your user repository by email (or username), then returns a UserDetails object built from your entity. Why does this matter? Because Spring Security's authentication manager calls loadUserByUsername every time a login request arrives. Without this custom service, your database never gets queried. The implementation is six lines: find user, throw UsernameNotFoundException if missing, map to a Spring Security User with roles. The failure point: forgetting to map roles as GrantedAuthority. Without authorities, all authenticated users get zero permissions—your @PreAuthorize annotations silently fail. Always extract roles from your entity and convert them to SimpleGrantedAuthority objects.
// io.thecodeforge — java tutorial // Maps your User entity to Spring Security's UserDetails @Service public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepo; public CustomUserDetailsService(UserRepository userRepo){ this.userRepo = userRepo; } @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { User user = userRepo.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email)); return new org.springframework.security.core.userdetails.User( user.getEmail(), user.getPassword(), user.isEnabled(), true, true, true, List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())) ); } }
The Secret Key That Ended Up in Git History — JWT Forging at Scale
- Never hardcode JWT secret keys in source code — use environment variables, Spring Cloud Config, or a secrets manager like HashiCorp Vault or AWS Secrets Manager
- A key in source code is a key in git history — even after deletion, the key remains in every commit that touched that file, forever, and GitHub search can surface it in seconds
- Implement key versioning for zero-downtime key rotation — accept old and new keys during a transition window so legitimate users aren't hard-logged out during an incident
- Add automated scanning in CI/CD for secret patterns — tools like git-secrets or truffleHog catch secrets before they reach the repository, not after the breach
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.echo '<TOKEN>' | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .curl -s http://localhost:8080/actuator/health | jq .components.jwtSecurityHealthkubectl get pods -o jsonpath='{.items[*].spec.containers[*].env[?(@.name=="JWT_SECRET")].value}'kubectl exec <pod> -- printenv JWT_SECRET | head -c 10mvn dependency:tree | grep jjwtecho '$JWT_SECRET' | base64 -d | wc -ckubectl logs <pod> | grep '/auth/login' | grep '401' | wc -lkubectl logs <pod> --since=1m | grep '/auth/login' | tail -20| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| io | public class JwtStructureDemo { | JWT Structure |
| io | public class AlgorithmComparison { | HS256 vs RS256 |
| pom.xml | Project Setup | |
| src | application: | Configuration |
| io | @Data | User Entity, Repository, and UserDetailsService |
| io | @Service | JwtService |
| io | @Slf4j | JwtAuthenticationFilter |
| io | @Configuration | SecurityConfig |
| io | @RestController | AuthenticationController |
| io | @RestController | Role-Based Access Control |
| io | @Component | Exception Handling |
| io | @Service | Token Storage on the Client |
| io | @Service | Token Blacklisting Strategies |
| io | /** | Production Security Checklist |
| RefreshTokenRotationService.java | @Service | The Refresh Token Rotation Strategy That Stops Token Theft C |
| RedisTokenBlacklistFilter.java | @Component | Why You Need a Token Blacklist, Not Just a Short Expiry |
| JwtAuthenticationFilter.java | @Component | What Is JwtFilter? The Gatekeeper That Never Sleeps |
| JwtService.java | @Service | Dependency Injection: JwtUtil Is Dead |
| AuthService.java | @Service | AuthService |
| JwtTokenProvider.java | @Component | JwtTokenProvider |
| CustomUserDetailsService.java | @Service | CustomUserDetailsService |
Key takeaways
Common mistakes to avoid
8 patternsStoring sensitive data in the JWT payload
Using a weak or hardcoded secret key
Setting infinite or very long token expiration
Not catching exceptions in JwtAuthenticationFilter
Not setting SessionCreationPolicy.STATELESS
Throwing exceptions in the filter when the token is missing
Storing JWTs in localStorage
Not adding @EnableMethodSecurity
Interview Questions on This Topic
Describe the 3 components of a JWT (Header, Payload, Signature). How is the signature generated, and what happens if someone modifies the payload?
Explain 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.
What is the difference between HS256 and RS256? When would you choose one over the other in a microservices architecture?
Walk me through the complete JWT authentication flow in Spring Boot: from the user clicking 'Login' to the client making an authenticated API call.
UserDetailsService.loadUserByUsername() to load the user from the database.
(4) DaoAuthenticationProvider calls BCryptPasswordEncoder.matches() to verify the submitted password against the stored hash.
(5) On success, JwtService generates an access token (15-min TTL) and refresh token (7-day TTL), both signed with HS256.
(6) Controller returns both tokens in the response body (200 OK).
(7) Client stores the access token in memory, refresh token in an HttpOnly cookie.
(8) Client sends GET /api/v1/profile with Authorization: Bearer <access-token> header.
(9) JwtAuthenticationFilter extracts the token, calls jwtService.extractUsername() which parses the token and verifies the signature.
(10) Filter loads UserDetails from UserDetailsService, calls jwtService.isTokenValid() to verify the token belongs to the user and isn't expired.
(11) Filter sets SecurityContextHolder with a UsernamePasswordAuthenticationToken containing the user's authorities.
(12) Spring Security checks the authorization rules — /api/v1/profile requires any authenticated user — authentication is set, so it passes.
(13) Controller executes, calls authentication.getName() to get the username, returns the profile data.
(14) When the access token expires, client sends POST /api/v1/auth/refresh with the refresh token, receives a new access token without re-entering credentials.Where should a JWT be stored on the client-side for maximum security? Compare localStorage, sessionStorage, and HttpOnly cookies.
How does the JwtAuthenticationFilter work? Why does it extend OncePerRequestFilter, and what happens if the token is missing or expired?
What is the purpose of the refresh token? Why not just use a long-lived access token?
Why is SessionCreationPolicy.STATELESS necessary when implementing JWT in Spring Security?
How would you implement role-based access control (RBAC) with JWT in Spring Boot?
Your JWT secret key has been compromised. Walk me through your incident response.
Frequently Asked Questions
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.
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.
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).
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.
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.
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()).
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.
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.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Spring Boot. Mark it forged?
21 min read · try the examples if you haven't