Chaturmind
LearnDSASystem DesignDevOpsEngineering 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

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

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.

Previous

Spring Security Overview

Next

Authentication Mechanics

AI Tutor

Lesson: JWT Authentication

Quick actions

AI responses can be inaccurate. Verify critical information.