logo
Pasted image 20250421171520.png

JWT and Session Management and Revoke Token Problem?

  • Author: Trần Trung
  • Published On: 22 Apr 2025
  • Category: System Design

Introduction: Background

JSON Web Tokens (JWT) have become a popular standard for authentication and authorization for modern web applications and APIs. The main characteristic of JWT is that it is stateless . The server does not need to store user session information; instead, all the information needed to authenticate the user (such as userId, roles) is securely encapsulated inside the token itself and sent with each request. This simplifies the architecture, especially in microservices systems or applications that need high scalability.

However, this very stateless nature poses a conundrum, which often arises while building real-world features:

"If the system uses JWT as an access token. How can a user revoke access to a specific device or when changing a password?"

If you implement JWT in the most basic way – just containing userId and authorization information in the payload – the answer is usually: "It is difficult, or impossible, to do this directly before the token expires." Because once the token has been issued and is valid (not expired exp, valid signature), the server will trust it by default without any further state checks.

This article will dive into a popular and effective solution to address this challenge, balancing the benefits of JWT with the needs of practical session management.

Basic JWT Approach and Limitations

When we first start with JWT, we are often instructed to create a token with a payload containing core information:

 

{
  "sub": "user-123", // Subject (user ID)
  "roles": ["user", "admin"],
  "iss": "my-auth-server", // Issuer
  "exp": 345343123 // Expiration time (Unix timestamp)
}

On the backend, a middleware will take on the following tasks:

  1. Extract token from Authorization header.
  2. Verify signature with secret key or public key.
  3. Check expiration time (exp).
  4. If everything is valid, extract the payload (e.g. userId) to use for request processing logic.

This approach works well for basic authentication needs. But it shows its limitations when you need to:

  • Revoke tokens before expiration: If a user loses their device or suspects their account has been compromised, there is no way to immediately invalidate access tokens issued to that device. Tokens will continue to be valid until they expire.
  • List active sessions: User cannot know how many devices he is logged in on.
  • Limit the number of concurrent sessions: It is not possible to impose a policy that only allows users to log in on N devices at the same time.
  • Force Logout All: When a user changes their password or an admin needs to lock their account urgently, it is not possible to easily disable all of that user's circulating tokens.

Solution: Incorporate Server-Side State

To overcome these limitations, we need to reintroduce some server-side state, but in a controlled way that doesn't completely destroy the benefits of JWT. A common solution is to use a database table (or another persistent store like Redis if you're willing to risk losing data on restart) to keep track of active sessions/tokens.

Let's call this table user_sessions.

1. Design user_sessions Table

This table needs to store the identifier information for each session/token issued and its state. A suggested structure:


CREATE TABLE user_sessions (
    id BIGSERIAL PRIMARY KEY,          
    user_id BIGINT NOT NULL,          
    jti VARCHAR(255) UNIQUE NOT NULL, 
    user_agent TEXT,                  
    ip_address VARCHAR(50),          
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMPTZ NOT NULL, 
    last_used_at TIMESTAMPTZ,        
    revoked_at TIMESTAMPTZ NULL,      
    is_revoked BOOLEAN DEFAULT FALSE  
    -- Optional
    INDEX idx_user_sessions_user_id (user_id),
    INDEX idx_user_sessions_jti (jti),
    INDEX idx_user_sessions_expires_at (expires_at)
);

Bottom line:

  • jti (JWT ID): This is a standard claim of JWT (defined in RFC 7519), used to provide a unique identifier for the token. When creating a JWT, we will generate a unique jti value (e.g. UUID) and put it in the payload. This jti value is also the value stored in the jti column in the user_sessions table. It acts as a bridge between the stateless JWT and the stateful record in the DB.
  • expires_at: Stores the token/session expiration time, making it easy to query and clean up later.
  • revoked_at / is_revoked: This column is used to mark a session/token as revoked.

2. New Token Creation and Validation Process

  • When User Logs In:
    1. Verify login information (username/password, social login...).
    2. If successful, generate a unique jti value (e.g. uuidv4()).
    3. Specifies the expiration time (exp) for the access token.
    4. Create a JWT payload containing sub (userId), jti, exp, and other necessary claims.
    5. Sign the JWT to generate the complete access token.
    6. Important: Create a new record in the user_sessions table with the user_id, the newly created jti, expires_at (equal to the exp of the JWT), and other metadata (IP, User Agent).
    7. Return access token to client.
  • When the Middleware Validates the Request:
    1. Extract token from request.
    2. Verify the JWT signature and exp as usual. If this step fails, reject it immediately.
    3. Additional step: Extract jti from JWT payload.
    4. Additional step: Query the user_sessions table to find the record with the corresponding jti.

      SELECT id, user_id, is_revoked, revoked_at
      FROM user_sessions
      WHERE jti = 'extracted_jti_value' AND expires_at > NOW();
    5. Additional Step: Check Query Results:
      • If no record is found (possibly due to a fake jti token, or the record has been deleted/expired): Reject the request.
      • If record found but is_revoked = true (or revoked_at IS NOT NULL): Reject request (token has been revoked).
      • If a valid record is found and has not been revoked: The request is valid, allowing it to proceed. Get the user_id from the DB record (or from the JWT payload) to use.

3. Performance Issues and Caching Solutions

Obviously, adding a DB query to each request will significantly increase latency, especially for high traffic systems. This is where caching comes into play.

  • Strategy: Cache the valid (or invalid) state of jti.
  • Cache Key: Use jti itself as cache key.
  • Cache Value:
    • Simple way: Save true if jti is valid and has not been revoked, false if it does not exist or has been revoked.
    • Better way: Save the state (valid/revoked) and the expiration time (expires_at of the session).
  • Cache Store:
    • In-memory cache (local): Suitable for single instance applications, fastest speed but not shared between instances.
    • Distributed cache (Redis, Memcached): Essential for applications running across multiple servers/containers, ensuring state consistency. Redis is often preferred because of its many data structures and persistent storage options.
  • Cache TTL (Time-To-Live): The TTL of a cache entry should be set no longer than the remaining validity time of the corresponding token/session (expires_at - NOW()). This ensures that the cache automatically expires when the token expires.
  • Cache Invalidation: This is a very important part. When a token is revoked (the record in user_sessions is updated with is_revoked = true), the system must immediately delete (invalidate) the cache entry corresponding to that jti. Otherwise, the middleware can still read from the old cache and allow the request to go through even though the token has been revoked in the DB.

With caching, the middleware authentication process would be:

  1. Verify signature/expiry JWT.
  2. Extract jti.
  3. Check cache: Find jti in cache.
    • If revoked value found -> Reject.
    • If valid value found -> Allow to continue (skip DB check).
    • If not found in cache (cache miss): a. Query DB user_sessions as described above. b. If DB reports valid: Save valid to cache with appropriate TTL -> Allow to continue. c. If DB reports invalid/revoked: Save revoked to cache (can be with short TTL or equal to TTL token) -> Reject.

4. Implement Revoke and Session Listing Features

With the user_sessions table, the desired features become possible:

  • List sessions:
    • Endpoint API: GET /api/user/sessions
    • Logic: Get user_id from current token, query user_sessions table to get all records that have not been revoked (is_revoked = false or revoked_at IS NULL) of that user. Return list containing information such as id (of session, not user), jti (or just id to revoke), user_agent, ip_address, created_at, last_used_at.
  • Revoke a specific session:
    • API Endpoint: DELETE /api/user/sessions/{session_id} (or use jti)
    • Logic: User selects session to revoke from list. Backend receives session_id (or jti).
    • Verify that the current user has the right to revoke this session (must be their own session).
    • Update the corresponding record in user_sessions: SET is_revoked = true, revoked_at = NOW() WHERE id = {session_id} AND user_id = {current_user_id}.
    • Important: Invalidate cache entry for jti of session has just been revoked.
  • Revoke all other sessions (Logout everywhere else):
    • API Endpoint: POST /api/user/sessions/revoke-others
    • Logic: Get the jti of the current token. Update all records in that user's user_sessions, except the record with the current jti: SET is_revoked = true, revoked_at = NOW() WHERE user_id = {current_user_id} AND jti != '{current_jti}'.
    • Invalidate cache for all affected jti.

5. Database Maintenance

The user_sessions table will grow large over time. There needs to be a periodic cleanup mechanism (e.g. cron job, scheduled task) to delete records that are truly expired (not just revoked).

  • Cleanup logic: DELETE FROM user_sessions WHERE expires_at < NOW() - interval 'X days' (X is a safe time to retain logs, e.g. 7 or 30 days).

6. Extended Benefits (Case Studies)

Maintaining the user_sessions table not only solves the revoke/listing problem, but also opens up many other possibilities:

  • Limit the number of concurrent sessions: Before creating a new session record when a user logs in, count the number of active sessions (is_revoked = false) of that user. If the limit is exceeded, deny the login or ask the user to revoke the old sessions.
  • Detect unusual logins: Save IP and User Agent history. If there is a new login from a different GEO Location or device than the history, the system can send a warning to the user or request further verification.
  • Detect stolen tokens (to some extent): If a jti (which is already marked is_revoked=true in the DB) appears in a request, it is a sign that the old token is being reused.
  • Force global logout on password change: When a user successfully changes their password, update all their session records to is_revoked = true, forcing them to log back in on all devices.
  • Analysis and Audit: The data in the user_sessions table provides valuable information about user login behavior.

Note about Refresh Tokens

Another popular model is to use a pair of Access Token (short-term, stateless) and Refresh Token (long-term, stateful). In this model:

  • Access Token can still be basic stateless (only contain userId, roles, exp) and have very short lifetime (few minutes to 1 hour). Middleware only needs to verify signature and exp.
  • The Refresh Token is stored securely on the client side (e.g. as an HttpOnly cookie) and a corresponding record (containing the jti or unique identifier of the refresh token) is stored in the user_sessions table (or refresh_tokens table).
  • When Access Token expires, client uses Refresh Token to request new Access Token. Server will check this Refresh Token with user_sessions/refresh_tokens table.
  • Revoking will be done on Refresh Token in DB. When Refresh Token is revoked, user cannot get new Access Token anymore. Old Access Token can still be used until it expires.

This approach reduces the DB/Cache check for each request using Access Token, but the revocation is not 100% instantaneous (depends on the remaining lifetime of the last issued Access Token). The choice between Access Token state management (as described in the article) or Refresh Token depends on the specific security and performance requirements.

Conclude

JWT offers many benefits in terms of statelessness and scalability, but to meet practical session management requirements such as revoke tokens, device enumeration, session restrictions, we often need to combine it with a server-side state storage mechanism (usually a DB table). By using jti claims to associate JWTs with state records and applying caching to optimize performance, we can build a robust, flexible, and secure authentication system that meets the expectations of both users and developers. This is the necessary balance between the stateless theory of JWT and the real-world needs of stateful management.

  • Share On: