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
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();
}
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