JWT Token Generation
- A JWT token consists of three parts: the Header (algorithm and token type), the Payload (business data like expiration and username), and the Signature (encrypts the header and payload using a secret key and algorithm). Typically,
Jwts.builder()handles the header automatically. - Create a
JwtUtilsutility class under theutilpackage dedicated to JWT generation and caching. - Generating a Token
// Private secret key – a Base64-encoded string stored in configuration
@Value("${jwt.secret-key}")
private String secretKeyBase64;
/**
* Decodes the Base64 secret key and returns a SecretKey object.
*/
private SecretKey getSigningKey() {
byte[] keyBytes = Base64.getDecoder().decode(secretKeyBase64);
return Keys.hmacShaKeyFor(keyBytes);
}
// Generate a unique token ID and expiration time
String tokenId = generateTokenId();
long expireTime = System.currentTimeMillis() + EXPIRATION_TIME;
// Create the token claims (a key-value map). Avoid storing sensitive information because the payload is only Base64 encoded.
Map<String, Object> claims = new HashMap<>();
claims.put("tokenId", tokenId); // For Redis caching
claims.put("role", user.getRole().name());
claims.put("userId", user.getId().toString()); // User ID for later use
// Finally generate the token using the signing key
SecretKey key = getSigningKey();
String token = Jwts.builder()
.setClaims(claims)
.setSubject(username)
.setExpiration(new Date(expireTime))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
- Caching the Token in Redis: Store user information (e.g., ID, name) in a map using the
tokenIdas the Redis key. Also, bind thetokenIdto the user's token set (using a Set data structure). Different Redis key prefixes help distinguish cache types. This bidirectional binding allows:- Querying all valid tokens for a user.
- Batch deletion when the user logs out.
- Limiting concurrent logins from multiple devices.
// Redis key prefix definitions
private static final String TOKEN_PREFIX = "jwt:valid:";
private static final String USER_TOKENS_PREFIX = "jwt:user:";
private static final String REFRESH_PREFIX = "jwt:refresh:";
private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
try {
String key = TOKEN_PREFIX + tokenId;
Map<String, Object> tokenInfo = new HashMap<>();
tokenInfo.put("userId", userId);
tokenInfo.put("username", username);
tokenInfo.put("expireTime", expireTimeMs);
// Redis TTL is slightly longer than the JWT expiration (extra 5 minutes buffer)
long ttlSeconds = (expireTimeMs - System.currentTimeMillis()) / 1000 + 300;
redisTemplate.opsForValue().set(key, tokenInfo, ttlSeconds, TimeUnit.SECONDS);
// Also add the tokenId to the user's token set
addTokenToUser(userId, tokenId, expireTimeMs);
logger.debug("Token cached: {} for user: {}", tokenId, username);
} catch (Exception e) {
logger.error("Failed to cache token: {}", tokenId, e);
}
-
Refresh Token Generation: The Access Token (AT) is short-lived (e.g., 1 hour), while the Refresh Token (RT) has a longer lifespan (e.g., 7 days). On login, both tokens are generated. When the AT expires, the front end uses the RT to call a
/refresh-tokenendpoint. The back end validates the RT and returns a new AT, so the user does not need to log in again. If the user changes their password, all RTs in Redis are immediately cleared, forcing a fresh login. Therefore, the login response must include both tokens. -
Extracting User Information from JWT: The claims (a key-value map) carry user data. Two extraction methods exist – one ignores expiration exceptions, the other does not. Ignoring expiration is useful when you still need some user info (e.g., when an AT is expired but you want to read the claims without bypassing authorization). After extracting the username from the token, the system queries the database for the full user record.
/**
* Extracts claims, ignoring expiration exception.
*/
private Claims extractClaimsIgnoreExpiration(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
} catch (Exception e) {
logger.debug("Cannot extract claims from token: {}", e.getMessage());
return null;
}
}
/**
* Extracts claims (throws exception if expired).
*/
private Claims extractClaims(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
return null;
}
}
/**
* Extracts the username from the JWT token.
* Returns null if token validation fails.
*/
public String extractUsernameFromToken(String token) {
try {
Claims claims = extractClaimsIgnoreExpiration(token);
return claims != null ? claims.getSubject() : null;
} catch (Exception e) {
logger.error("Error extracting username from token: {}", token, e);
return null;
}
}