A new approach to JWT revocation
Revoking JWT sessions without blacklists? Embed a user-specific secret in tokens via a signed JTI claim (`sjti`). Validate first with your app secret, then the user’s secret. Rotate their secret to revoke all sessions instantly. Secure, scalable, and stateless.
JSON Web Tokens (JWTs) have become a staple in modern web application authentication. Their stateless nature offers scalability and ease of use, but they present a significant challenge: revocation. Once a JWT is issued, it remains valid until its expiration, even if the user's access should be terminated. Today we introduce a robust solution to this problem, and a clever approach to JWT validation.
The JWT Revocation Conundrum
Traditional session management relies on server-side storage to track user sessions. Revocation is as simple as deleting the session data. JWTs, however, are self-contained and validated without consulting a central authority, making immediate revocation difficult.
Common workarounds like maintaining a blacklist of revoked tokens introduce statefulness, negating the benefits of JWTs. Short-lived tokens mitigate the risk but can lead to a poor user experience with frequent re-authentication.
The Solution: User-Specific Signed JTIs
Our approach leverages a user-specific secret to create a Signed JWT ID (SJTI). This method allows us to invalidate all of a user's JWTs instantly by simply rotating their secret. In reality, this strategy works with any claim besides JTI.
The Core Idea
- Each user has a secret: Stored securely in the database.
jti
Claim: Each JWT includes a uniquejti
(JWT ID).sjti
Claim: A signature of thejti
using the user's secret. This is added as a custom claim in the JWT.- Validation: On each request, we validate the JWT's signature using the application secret and then validate the
sjti
using the user's secret. - Revocation: To revoke all tokens for a user, we simply generate a new secret for that user. Existing tokens will fail the
sjti
validation.
Benefits
- Stateless Validation: The core validation remains stateless. We only need to consult the database for the user's secret, which we'd likely do anyway for authorization checks.
- Instant Revocation: Rotating the user's secret invalidates all existing tokens immediately.
- Scalability: This approach scales well in distributed systems.
A simple Go implementation
Let's dive into the implementation details using Go and Postgres.
1. Database Setup (Postgres)
First, we need to add a token_secret
column to our users
table:
ALTER TABLE users ADD COLUMN token_secret;
We use a UUID for the secret for added security.
2. Code
a. JWT Generation
func generateToken(userID string) (string, error) {
// Get user's secret from the database
user, err := getUser(userID)
if err != nil {
return "", fmt.Errorf("could not get user: %w", err)
}
userSecret, err := decryptUserSecret(user.TokenSecret)
if err != nil {
return "", fmt.Errorf("could not get user secret: %w", err)
}
// Generate unique JTI
jti, err := uuid.NewRandom()
if err != nil {
return "", fmt.Errorf("could not generate JTI: %w", err)
}
// Calculate SJTI
sjti := calculateHMAC(userSecret, jti.String())
// Set claims
claims := jwt.MapClaims{
"iss": "your-application",
"sub": userID,
"exp": time.Now().Add(time.Hour * 1).Unix(), // 1 hour
"iat": time.Now().Unix(),
"jti": jti.String(),
"sjti": sjti,
}
// Sign the token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, err := token.SignedString([]byte(os.Getenv("APP_SECRET")))
if err != nil {
return "", fmt.Errorf("could not sign token: %w", err)
}
return signedToken, nil
}
b. JWT Validation
func validateToken(tokenString string) (bool, string, error) {
// Parse token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(os.Getenv("APP_SECRET")), nil
})
if err != nil {
return false, "", fmt.Errorf("could not parse token: %w", err)
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
userID := claims["sub"].(string)
jti := claims["jti"].(string)
sjti := claims["sjti"].(string)
// Get user's secret from the database
user, err := getUser(userID)
if err != nil {
return "", fmt.Errorf("could not get user: %w", err)
}
userSecret, err := decryptUserSecret(user.TokenSecret)
if err != nil {
return "", fmt.Errorf("could not get user secret: %w", err)
}
// Calculate expected SJTI
expectedSJTI := calculateHMAC(userSecret, jti)
// Compare SJTIs
if hmac.Equal([]byte(sjti), []byte(expectedSJTI)) {
return true, userID, nil
}
return false, "", fmt.Errorf("invalid SJTI")
}
return false, "", fmt.Errorf("invalid token")
}
c. JWT Revocation
func revokeUserSessions(userID string) error {
// Generate new secret
newSecret, err := uuid.NewRandom()
if err != nil {
return fmt.Errorf("could not generate new secret: %w", err)
}
encryptedSecret, err := decryptUserSecret(newSecret.String());
if err != nil {
return fmt.Errorf("could not encrypt user secret: %w", err)
}
// Update the user's secret in the database
err = updateUserSecret(userID, encryptedSecret)
if err != nil {
return fmt.Errorf("could not update user secret: %w", err)
}
return nil
}
3. Middleware Implementation example
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get token from header
tokenString := r.Header.Get("Authorization")
if tokenString == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Validate the token
valid, userID, err := validateToken(tokenString)
if !valid {
http.Error(w, "Unauthorized: "+err.Error(), http.StatusUnauthorized)
return
}
// Add user ID to context (optional)
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Comparison with Other Approaches
Feature | User-Specific Signed JTIs | Token Blacklist | Short-Lived Tokens |
---|---|---|---|
Revocation | Immediate | Immediate | After Expiration |
Statelessness | Mostly | No | Yes |
Complexity | Moderate | High | Low |
Performance Overhead | Low | High | Low |