Chaturmind
LearnDSASystem DesignInterview PrepDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth
  • Java Interview Prep

Company

  • About
  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

👥10,051visitors

Built for engineers who want to go deep.


← Spring Security & JWT Auth

Spring Security Basics

  • Spring Security Overview
  • JWT Authentication
  • Authentication Mechanics

Authorization

  • Role-Based Access Control
  • Password Encoding
  • OAuth2 & Social Login Basics
Chaturmind
← Spring Security & JWT Auth

Spring Security Basics

  • Spring Security Overview
  • JWT Authentication
  • Authentication Mechanics

Authorization

  • Role-Based Access Control
  • Password Encoding
  • OAuth2 & Social Login Basics
HomeLearnSpring BootSpring Security & JWT AuthSpring Security Basics
✓ FreeIntermediate· 15 min read

JWT Authentication

Issue and verify JWTs — stateless auth with Spring Security 6 and java-jwt.

Published September 21, 2026


JWT Authentication in Spring Boot

JWT (JSON Web Token) enables stateless authentication — the server doesn't store sessions. Instead, each request carries a signed token that the server validates.

JWT Structure

Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← Base64(Header)
.eyJzdWIiOiJ1c2VyMTIzIiwiZXhwIjoxNjk5fQ ← Base64(Payload)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← HMAC signature

Payload (claims):

{
  "sub": "user@example.com",
  "iat": 1699000000,
  "exp": 1699086400,
  "roles": ["ROLE_USER"]
}

Dependency

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.3</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.3</version>
    <scope>runtime</scope>
</dependency>

Custom JWT filter: OncePerRequestFilter

class JwtAuthenticationFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String token = extractToken(request);
        if (token != null && jwtService.isValid(token)) {
            Authentication auth = jwtService.buildAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth); // populate for the rest of THIS request
        }
        chain.doFilter(request, response); // always pass through — let downstream authorization decide
    }
}

OncePerRequestFilter guarantees its filtering logic runs exactly once per request, even in servlet environments where a request might otherwise be internally forwarded/dispatched multiple times — important for a filter validating and setting authentication state, since running it twice could waste work or, worse, interact awkwardly with state set by the first pass.

Refresh token strategy

A short-lived access token (minutes) is sent with every request; a longer-lived refresh token (days/weeks) is used only to obtain a new access token once the current one expires, without requiring the user to log in again. Rotation on use — issuing a brand-new refresh token every time the old one is used to refresh — means a leaked refresh token has a bounded window of usefulness: once the legitimate client uses it and gets a new one, the old (potentially leaked) token becomes invalid.

The token revocation problem — and its mitigation

Stateless JWTs are validated purely by signature — there's no server-side session to delete, so a compromised or logged-out token remains technically "valid" until it naturally expires, unless the server does extra work to prevent it. The standard mitigation: keep access-token expiry short (minutes, not hours), and maintain a small denylist cache (Redis, TTL matching the token's remaining validity) of explicitly revoked token IDs, checked on each request — this reintroduces a small amount of server-side state specifically to close the revocation gap, a deliberate, bounded exception to full statelessness.

Validating the alg header — preventing algorithm-confusion attacks

A JWT's header declares which signing algorithm was used (e.g. HS256, RS256). A known attack class exploits libraries that trust this header blindly: an attacker crafts a token claiming a different, weaker algorithm than the server actually uses (a classic version swaps an asymmetric RS256-signed setup for HS256, using the public key — normally safe to expose — as the HMAC secret). Secure verification code must explicitly specify and enforce the expected algorithm server-side, never trust the algorithm named in the token's own header.

OAuth2 vs JWT — different problems

OAuth2 is an authorization framework defining how a client obtains an access token on a resource owner's behalf, via defined grant types (authorization code, client credentials, etc.) — it says nothing about the token's actual format. JWT is a token format/encoding — OAuth2 commonly issues JWTs as its access tokens (a natural pairing), but the two solve genuinely different problems and aren't interchangeable: you could run OAuth2 with opaque, non-JWT tokens, and you could use JWTs entirely outside any OAuth2 flow, as this lesson's own custom filter does.

JwtService

@Service
public class JwtService {

    @Value("${app.jwt.secret}")
    private String secret;

    @Value("${app.jwt.expiration-ms:86400000}")
    private long expirationMs;

    private SecretKey key() {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
    }

    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
            .subject(userDetails.getUsername())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expirationMs))
            .claim("roles", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority).toList())
            .signWith(key())
            .compact();
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public boolean isValid(String token, UserDetails userDetails) {
        String username = extractUsername(token);
        return username.equals(userDetails.getUsername())
            && !isExpired(token);
    }

    private boolean isExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private <T> T extractClaim(String token, Function<Claims, T> resolver) {
        Claims claims = Jwts.parser()
            .verifyWith(key())
            .build()
            .parseSignedClaims(token)
            .getPayload();
        return resolver.apply(claims);
    }
}

JWT Filter

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);
        String username = jwtService.extractUsername(token);

        if (username != null &&
                SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            if (jwtService.isValid(token, userDetails)) {
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        chain.doFilter(request, response);
    }
}

Auth Controller

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authManager;
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @PostMapping("/login")
    public ResponseEntity<Map<String, String>> login(
            @RequestBody @Valid LoginRequest req) {
        authManager.authenticate(
            new UsernamePasswordAuthenticationToken(req.email(), req.password()));

        UserDetails user = userDetailsService.loadUserByUsername(req.email());
        String token = jwtService.generateToken(user);
        return ResponseEntity.ok(Map.of("token", token));
    }
}

Interview Tips

  1. JWT is not encrypted by default (only signed) — don't put sensitive data in the payload. Anyone can decode it with Base64.
  2. Refresh tokens: short-lived access tokens (15 min) + long-lived refresh tokens (7 days) stored securely. The access token is stateless; refresh tokens are stored in a DB for revocation.
  3. Logout with JWT: Since tokens are stateless, you need a token blacklist (Redis set) or rely on short expiration.

Advertisement

The request flow, end to end

It helps to trace one request through the code above:

  1. Login: the client posts credentials to /auth/login. The AuthenticationManager verifies them (via UserDetailsService and the PasswordEncoder). On success, JwtService signs a short-lived access token containing the user ID, roles and an expiry, and usually issues a refresh token too.
  2. Authenticated request: the client sends Authorization: Bearer <token>. The OncePerRequestFilter extracts it, verifies the signature and expiry, builds an Authentication object with the user's authorities, and places it in the SecurityContextHolder.
  3. Authorization: later filters and @PreAuthorize checks read that Authentication. The server never looks up a session, which is the whole point of stateless tokens.
  4. Expiry: when the access token expires, the client calls the refresh endpoint with its refresh token, and gets a new access token without asking the user to log in again.

Nothing in the token is secret. A JWT is signed, not encrypted by default, and anyone can Base64-decode the payload. Never put passwords, personal data or internal secrets in claims.

Where to store tokens in a browser

StorageXSS riskCSRF riskNotes
localStorage / sessionStorageHigh: any injected script can read and exfiltrate the tokenNoneSimple, but one XSS bug leaks every user's token
In-memory JS variableLower: lost on reloadNonePair with a refresh token cookie to restore sessions
HttpOnly, Secure, SameSite cookieScript can't read itMitigated by SameSite=Lax/Strict, plus CSRF tokens for unsafe methodsUsually the safest choice for web apps

A common robust setup: the access token lives in memory, and the refresh token lives in an HttpOnly; Secure; SameSite=Strict cookie scoped to the refresh endpoint's path. Mobile apps should use the platform's secure storage (Keychain, Keystore).

Signing keys and rotation

  • HS256 (HMAC) uses one shared secret for signing and verifying. It's fine when one service both issues and validates tokens. The secret must be long and random (at least 256 bits), and never committed to source control.
  • RS256 / ES256 (asymmetric) signs with a private key, and verifies with a public key. Use this when several services validate tokens: they only need the public key, often fetched from a JWKS endpoint, and can't mint tokens themselves.
  • Rotation: put a key ID (kid) in the token header, publish the old and new public keys together during a transition, sign new tokens with the new key, and retire the old key after the longest token lifetime has passed.

Follow-up questions this topic invites — and their answers

Q: Is a JWT encrypted? A: Not normally. A signed JWT (JWS) is only Base64URL-encoded, so anyone can read its claims. The signature just proves they weren't modified. Use JWE (encrypted JWT) if claims must be confidential, or better, keep sensitive data out of tokens.

Q: How long should access tokens live? A: Short: typically 5–15 minutes. A short lifetime limits the damage from a stolen token, since revocation is otherwise hard with stateless tokens. Refresh tokens can live much longer, because they're stored and checked server-side, and can be revoked.

Q: How do you log a user out with stateless JWTs? A: Delete or revoke their refresh token server-side, so they can't get new access tokens, and let the client discard the current access token, which expires within minutes. For immediate revocation, keep a short-lived deny-list of token IDs (jti), or a per-user "tokens issued before this time are invalid" timestamp.

Q: Why validate the alg header, or pin the algorithm? A: Some old libraries trusted the token's own alg value. Attackers could send alg: none (no signature), or trick an RS256 verifier into treating the public key as an HMAC secret. Always configure the expected algorithm and key on the server, and reject tokens that don't match.

Q: Sessions or JWTs: which should a typical web app use? A: For a single server-rendered or same-site app, server-side sessions with a secure cookie are simpler, and easy to revoke. JWTs help when many services must validate identity without a shared session store, or with third-party and mobile clients. Choose based on architecture, not fashion.

Advertisement

Previous

Spring Security Overview

Next

Authentication Mechanics

AI Tutor

Lesson: JWT Authentication

Quick actions

AI responses can be inaccurate. Verify critical information.