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

  1. Each user has a secret: Stored securely in the database.
  2. jti Claim: Each JWT includes a unique jti (JWT ID).
  3. sjti Claim: A signature of the jti using the user's secret. This is added as a custom claim in the JWT.
  4. Validation: On each request, we validate the JWT's signature using the application secret and then validate the sjti using the user's secret.
  5. 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

Subscribe to Nellcorp Blog

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe