JWT (JSON Web Tokens)

Stateless authentication for modern applications

← Back to Index

What is JWT?

JWT (JSON Web Token) is an open standard (RFC 7519) for securely transmitting information between parties as a JSON object. JWTs are commonly used for authentication and authorization in web applications, especially in stateless architectures like REST APIs.

Key Insight: Unlike traditional sessions where the server stores user state, JWTs contain all the information needed to verify the user. The server doesn't need to remember anything - the token itself is the proof of authentication.

JWT vs Session-Based Authentication

// Session-Based (Stateful):
┌──────────┐     Login      ┌──────────┐
│  Client  │ ─────────────▶ │  Server  │
└──────────┘                └──────────┘
     │                           │
     │    Session ID: abc123     │ Stores session in DB/memory
     │ ◀─────────────────────────│ session["abc123"] = {user: "john"}
     │                           │
     │    Cookie: sessionId=abc123│
     │ ──────────────────────────▶│ Lookup session["abc123"]
     │                           │ Returns user data

// JWT-Based (Stateless):
┌──────────┐     Login      ┌──────────┐
│  Client  │ ─────────────▶ │  Server  │
└──────────┘                └──────────┘
     │                           │
     │    JWT: eyJhbGci...       │ Creates signed token
     │ ◀─────────────────────────│ Contains: {user: "john", exp: ...}
     │                           │
     │    Authorization: Bearer eyJ...│
     │ ──────────────────────────▶│ Verifies signature
     │                           │ Extracts user from token
     │                           │ NO database lookup needed!
Aspect Session-Based JWT-Based
State Stateful (server stores sessions) Stateless (token contains everything)
Storage Server: Database/Memory, Client: Cookie Client only: localStorage/Cookie
Scalability Requires shared session store Easy horizontal scaling
Revocation Easy (delete from store) Difficult (needs blocklist)
Cross-domain Complex (CORS cookies) Easy (Authorization header)

JWT Structure

A JWT consists of three parts separated by dots: header.payload.signature

// Example JWT (line breaks added for readability):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE1MTYyMzkwMjJ9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

// Structure visualization:
┌───────────────────────────────────────────────────────────────────┐
│                         JWT Token                                  │
├─────────────────┬──────────────────────┬──────────────────────────┤
│     HEADER      │       PAYLOAD        │       SIGNATURE          │
│   (Algorithm)   │    (Claims/Data)     │     (Verification)       │
├─────────────────┼──────────────────────┼──────────────────────────┤
│ {               │ {                    │ HMACSHA256(              │
│   "alg": "HS256"│   "sub": "123",      │   base64(header) + "." + │
│   "typ": "JWT"  │   "name": "John",    │   base64(payload),       │
│ }               │   "iat": 1516239022, │   secret                 │
│                 │   "exp": 1516242622  │ )                        │
│                 │ }                    │                          │
└─────────────────┴──────────────────────┴──────────────────────────┘
     Base64URL          Base64URL              Base64URL

1. Header

// The header typically contains:
{
    "alg": "HS256",    // Algorithm used for signing
    "typ": "JWT"       // Type of token
}

// Common algorithms:
// HS256 - HMAC with SHA-256 (symmetric)
// RS256 - RSA with SHA-256 (asymmetric)
// ES256 - ECDSA with SHA-256 (asymmetric)

2. Payload (Claims)

// The payload contains claims (statements about the user)
{
    // Registered Claims (standard, optional but recommended)
    "iss": "https://myapp.com",    // Issuer
    "sub": "user123",              // Subject (user ID)
    "aud": "https://api.myapp.com",// Audience
    "exp": 1700000000,             // Expiration (Unix timestamp)
    "nbf": 1699990000,             // Not Before
    "iat": 1699990000,             // Issued At
    "jti": "unique-token-id",     // JWT ID (for revocation)

    // Public Claims (custom, should avoid collision)
    "name": "John Doe",
    "email": "john@example.com",

    // Private Claims (application-specific)
    "roles": ["ADMIN", "USER"],
    "permissions": ["read", "write"],
    "tenant_id": "acme-corp"
}

3. Signature

// The signature ensures the token hasn't been tampered with

// For HS256 (symmetric - same key for signing and verifying):
signature = HMACSHA256(
    base64UrlEncode(header) + "." + base64UrlEncode(payload),
    "your-256-bit-secret"
)

// For RS256 (asymmetric - private key signs, public key verifies):
signature = RSASHA256(
    base64UrlEncode(header) + "." + base64UrlEncode(payload),
    privateKey
)
// Verification uses the public key
Security Warning

The payload is NOT encrypted! Anyone can decode a JWT and read its contents. Never put sensitive information (passwords, credit cards, etc.) in a JWT. The signature only prevents tampering, not reading.

JWT Implementation in Java

Dependencies (Maven)

<!-- JJWT library (most popular) -->
<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>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
</dependency>

JWT Service Implementation

@Service
public class JwtService {

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

    @Value("${jwt.expiration:3600000}")  // 1 hour default
    private long jwtExpiration;

    @Value("${jwt.refresh-expiration:604800000}")  // 7 days
    private long refreshExpiration;

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    // Generate access token
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    // Generate token with extra claims
    public String generateToken(
            Map<String, Object> extraClaims,
            UserDetails userDetails) {

        return Jwts.builder()
            .claims(extraClaims)
            .subject(userDetails.getUsername())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + jwtExpiration))
            .signWith(getSigningKey())
            .compact();
    }

    // Generate refresh token (longer expiry, minimal claims)
    public String generateRefreshToken(UserDetails userDetails) {
        return Jwts.builder()
            .subject(userDetails.getUsername())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + refreshExpiration))
            .claim("type", "refresh")
            .signWith(getSigningKey())
            .compact();
    }

    // Extract username from token
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // Extract expiration date
    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // Generic claim extraction
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    // Extract all claims
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }

    // Validate token
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername())
            && !isTokenExpired(token);
    }

    // Check if token is expired
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }
}

JWT Authentication Filter

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtService jwtService;

    @Autowired
    private UserDetailsService userDetailsService;

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

        // Get Authorization header
        final String authHeader = request.getHeader("Authorization");

        // Check for Bearer token
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Extract token
        final String jwt = authHeader.substring(7);

        try {
            // Extract username from token
            final String username = jwtService.extractUsername(jwt);

            // If username exists and not already authenticated
            if (username != null &&
                SecurityContextHolder.getContext().getAuthentication() == null) {

                // Load user details
                UserDetails userDetails = userDetailsService
                    .loadUserByUsername(username);

                // Validate token
                if (jwtService.isTokenValid(jwt, userDetails)) {
                    // Create authentication token
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                        );

                    // Add request details
                    authToken.setDetails(
                        new WebAuthenticationDetailsSource()
                            .buildDetails(request)
                    );

                    // Set authentication in context
                    SecurityContextHolder.getContext()
                        .setAuthentication(authToken);
                }
            }
        } catch (ExpiredJwtException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Token expired");
            return;
        } catch (JwtException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Invalid token");
            return;
        }

        filterChain.doFilter(request, response);
    }
}

Authentication Controller

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

    @Autowired
    private AuthenticationManager authManager;

    @Autowired
    private JwtService jwtService;

    @Autowired
    private UserDetailsService userDetailsService;

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(
            @RequestBody LoginRequest request) {

        // Authenticate credentials
        authManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getUsername(),
                request.getPassword()
            )
        );

        // Load user details
        UserDetails user = userDetailsService
            .loadUserByUsername(request.getUsername());

        // Generate tokens
        String accessToken = jwtService.generateToken(user);
        String refreshToken = jwtService.generateRefreshToken(user);

        return ResponseEntity.ok(new AuthResponse(
            accessToken,
            refreshToken,
            jwtService.extractExpiration(accessToken).getTime()
        ));
    }

    @PostMapping("/refresh")
    public ResponseEntity<AuthResponse> refresh(
            @RequestBody RefreshRequest request) {

        String refreshToken = request.getRefreshToken();

        // Validate refresh token
        String username = jwtService.extractUsername(refreshToken);
        UserDetails user = userDetailsService.loadUserByUsername(username);

        if (!jwtService.isTokenValid(refreshToken, user)) {
            throw new InvalidTokenException("Invalid refresh token");
        }

        // Generate new access token
        String newAccessToken = jwtService.generateToken(user);

        return ResponseEntity.ok(new AuthResponse(
            newAccessToken,
            refreshToken,  // Keep same refresh token
            jwtService.extractExpiration(newAccessToken).getTime()
        ));
    }
}

// Response DTO
public record AuthResponse(
    String accessToken,
    String refreshToken,
    long expiresAt
) {}

Token Storage Best Practices

Where to Store JWTs

Storage XSS Risk CSRF Risk Best For
localStorage High (accessible via JS) None SPAs with XSS protection
sessionStorage High (accessible via JS) None Single-tab sessions
HttpOnly Cookie None (not accessible via JS) High (needs CSRF token) Traditional web apps
Memory only Low None High-security SPAs

Secure Cookie Configuration

@PostMapping("/login")
public ResponseEntity<Void> login(
        @RequestBody LoginRequest request,
        HttpServletResponse response) {

    // ... authenticate and generate token ...
    String token = jwtService.generateToken(user);

    // Set token in HttpOnly cookie
    ResponseCookie cookie = ResponseCookie.from("jwt", token)
        .httpOnly(true)         // Not accessible via JavaScript
        .secure(true)           // Only sent over HTTPS
        .sameSite("Strict")     // Prevents CSRF
        .path("/")               // Available for all paths
        .maxAge(Duration.ofHours(1))
        .build();

    response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

    return ResponseEntity.ok().build();
}

// Read JWT from cookie in filter
private String extractTokenFromCookie(HttpServletRequest request) {
    if (request.getCookies() != null) {
        return Arrays.stream(request.getCookies())
            .filter(c -> "jwt".equals(c.getName()))
            .map(Cookie::getValue)
            .findFirst()
            .orElse(null);
    }
    return null;
}

Token Revocation Strategies

JWTs are stateless and can't be invalidated before expiration by default. Here are strategies to handle revocation:

1. Short Expiration + Refresh Tokens

// Access token: 15 minutes
// Refresh token: 7 days (stored in database)

@Entity
public class RefreshToken {
    @Id
    private String token;

    @ManyToOne
    private User user;

    private Instant expiryDate;

    private boolean revoked = false;
}

// Revoke all user's refresh tokens on logout or password change
@Transactional
public void revokeAllUserTokens(User user) {
    refreshTokenRepository.revokeAllByUser(user);
}

2. Token Blocklist

@Service
public class TokenBlocklistService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // Add token to blocklist with TTL matching token expiry
    public void blockToken(String token) {
        Date expiry = jwtService.extractExpiration(token);
        long ttl = expiry.getTime() - System.currentTimeMillis();

        if (ttl > 0) {
            redisTemplate.opsForValue()
                .set("blocked:" + token, "revoked",
                    ttl, TimeUnit.MILLISECONDS);
        }
    }

    // Check if token is blocked
    public boolean isBlocked(String token) {
        return redisTemplate.hasKey("blocked:" + token);
    }
}

// In JWT filter, add blocklist check
if (tokenBlocklistService.isBlocked(jwt)) {
    response.setStatus(401);
    response.getWriter().write("Token has been revoked");
    return;
}

3. Token Versioning

// Store token version in user record
@Entity
public class User {
    // ...
    private int tokenVersion = 0;  // Increment to invalidate all tokens
}

// Include version in JWT
public String generateToken(User user) {
    return Jwts.builder()
        .subject(user.getUsername())
        .claim("version", user.getTokenVersion())
        .signWith(getSigningKey())
        .compact();
}

// Validate version during authentication
public boolean isTokenValid(String token, User user) {
    Integer tokenVersion = extractClaim(token,
        claims -> claims.get("version", Integer.class));

    return extractUsername(token).equals(user.getUsername())
        && !isTokenExpired(token)
        && tokenVersion.equals(user.getTokenVersion());
}

// Invalidate all user's tokens
public void invalidateAllTokens(User user) {
    user.setTokenVersion(user.getTokenVersion() + 1);
    userRepository.save(user);
}

Security Best Practices

JWT Security Checklist

  • Use strong secrets: At least 256 bits for HS256, properly generated keys for RS256
  • Set short expiration: Access tokens should expire in 15-60 minutes
  • Validate all claims: Check issuer, audience, expiration, not-before
  • Use HTTPS only: Never transmit JWTs over unencrypted connections
  • Don't store sensitive data: JWTs are not encrypted by default
  • Implement refresh token rotation: Issue new refresh token on each use
  • Validate algorithm: Prevent "none" algorithm attacks

Common Vulnerabilities to Avoid

// VULNERABILITY 1: Algorithm confusion attack
// Attacker changes alg to "none" or HS256 when RS256 expected

// SECURE: Explicitly specify expected algorithm
Jwts.parser()
    .verifyWith(key)  // This enforces the correct algorithm
    .build()
    .parseSignedClaims(token);

// VULNERABILITY 2: Weak secret
// DON'T: String secret = "secret123";
// DO: Use cryptographically secure random bytes
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

// VULNERABILITY 3: Token in URL
// DON'T: /api/data?token=eyJ...
// DO: Use Authorization header

// VULNERABILITY 4: Missing expiration validation
// Always check exp claim
if (isTokenExpired(token)) {
    throw new TokenExpiredException();
}
Never Trust Client Input

Always validate JWTs on the server. The signature verification is what makes JWTs secure - without it, anyone could create a fake token with any claims they want.

Summary

  • JWT = Header + Payload + Signature (three Base64URL parts)
  • Stateless: Server doesn't store session data
  • Claims: Standard (iss, sub, exp) and custom (roles, permissions)
  • Not encrypted: Anyone can read the payload; signature prevents tampering
  • Short-lived: Access tokens should expire quickly (15-60 min)
  • Refresh tokens: Longer-lived, used to get new access tokens
  • Revocation: Use blocklists, short expiry, or token versioning