JJWT Decoder
10 min read

How to Handle JWT Token Expiration Gracefully

Master JWT token expiration handling with the exp claim, grace periods, and auto-refresh flows. Handle expired tokens gracefully.

In This Article

Understanding JWT Token Expiration

Every JWT should have an expiration time. Without one, a stolen token is valid forever — a massive security risk. The exp (expiration time) claim is one of the seven registered claims defined in RFC 7519 Section 4.1.4, and it's arguably the most important for security.

In this guide, we'll cover how expiration works, strategies for handling expired tokens gracefully, and how to build auto-refresh flows that keep users authenticated without compromising security. For a broader overview of JWT claims, see our What is JWT guide.

The exp Claim Explained

The exp claim contains a Unix timestamp (NumericDate) representing the expiration time of the token. After this time, the token MUST be rejected by any conforming implementation.

{
  "sub": "user123",
  "iat": 1720627200,
  "exp": 1720630800
}

In this example, the token was issued at 1720627200 (July 10, 2024 12:00:00 UTC) and expires at 1720630800 (one hour later). Any verification after the expiration time should fail.

Other Time-Based Claims

JWT includes several time-related claims that work together:

  • iat (Issued At): When the token was created
  • nbf (Not Before): The earliest time the token is valid
  • exp (Expiration Time): When the token becomes invalid

A token is valid only when: nbf <= current_time < exp

Setting Appropriate Token Lifetimes

The right token lifetime balances security and user experience:

Short-Lived Access Tokens (15 minutes – 1 hour)

  • Limit the window of opportunity for stolen tokens
  • Reduce the impact of token leakage
  • Force more frequent refresh, enabling permission updates

Medium-Lived Tokens (1 – 24 hours)

  • Suitable for internal APIs or trusted environments
  • Reduce refresh overhead for long user sessions
  • Acceptable when combined with token revocation strategies

Long-Lived Tokens (days to weeks)

  • Only appropriate for refresh tokens or API keys
  • Require strong revocation mechanisms
  • Should never be used as access tokens for web APIs

The general recommendation is 15 minutes for access tokens combined with refresh tokens for seamless session continuation.

Server-Side Expiration Validation

Always validate expiration on the server. Never trust the client to check token validity.

const jwt = require('jsonwebtoken');

const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;

function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
      algorithms: ['HS256'],
      clockTolerance: 30, // 30 seconds tolerance for clock skew
      maxAge: '1h' // reject tokens older than 1 hour even if exp hasn't passed
    });
    return { valid: true, decoded };
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return { valid: false, reason: 'expired', expiredAt: err.expiredAt };
    }
    if (err.name === 'NotBeforeError') {
      return { valid: false, reason: 'not_yet_valid', date: err.date };
    }
    return { valid: false, reason: 'invalid_token' };
  }
}

// Express middleware
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const result = verifyToken(authHeader.split(' ')[1]);
  if (!result.valid) {
    if (result.reason === 'expired') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }

  req.user = result.decoded;
  next();
}

Notice the clockTolerance option — this is crucial for handling clock skew between servers.

Handling Clock Skew

In distributed systems, server clocks are rarely perfectly synchronized. A token that appears expired on one server might still be valid on another. Here's how to handle it:

Server-Side Strategies

  1. Clock tolerance: Allow a small grace period (30–60 seconds) when checking expiration
  2. NTP synchronization: Ensure all servers sync to a reliable time source
  3. Issuer-side padding: Add a small buffer to the exp claim when issuing tokens

Client-Side Strategy

Check expiration locally before sending requests, using a small buffer:

function isTokenExpiringSoon(token, bufferSeconds = 30) {
  try {
    const payload = JSON.parse(atob(token.split('.')[1]));
    const now = Math.floor(Date.now() / 1000);
    return payload.exp - bufferSeconds < now;
  } catch {
    return true; // treat unreadable tokens as expired
  }
}

// Before making an API call
async function apiCall(url, options = {}) {
  let token = getAccessToken();

  if (isTokenExpiringSoon(token)) {
    token = await refreshAccessToken();
  }

  options.headers = {
    ...options.headers,
    Authorization: `Bearer ${token}`
  };

  return fetch(url, options);
}

Graceful Expiration Handling on the Client

When a token does expire, the user experience shouldn't be jarring. Here's a strategy for handling expiration gracefully:

Proactive Refresh

Instead of waiting for the token to expire, refresh it proactively when it's close to expiring:

class TokenManager {
  constructor() {
    this.accessToken = null;
    this.refreshTimer = null;
  }

  setToken(accessToken, expiresIn) {
    this.accessToken = accessToken;
    // Refresh 60 seconds before expiration
    const refreshIn = (expiresIn - 60) * 1000;
    this.scheduleRefresh(refreshIn);
  }

  scheduleRefresh(delay) {
    clearTimeout(this.refreshTimer);
    this.refreshTimer = setTimeout(async () => {
      try {
        const data = await this.doRefresh();
        this.setToken(data.accessToken, data.expiresIn);
      } catch (err) {
        console.error('Token refresh failed:', err);
        // Redirect to login
        window.location.href = '/login';
      }
    }, delay);
  }

  async doRefresh() {
    const response = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include' // send refresh cookie
    });
    if (!response.ok) throw new Error('Refresh failed');
    return response.json();
  }

  getToken() {
    return this.accessToken;
  }
}

const tokenManager = new TokenManager();

Retry on 401

Combine proactive refresh with a 401 retry mechanism (as shown in our refresh token guide). This ensures that even if proactive refresh fails, the client gets a second chance.

Grace Period Strategy

A grace period allows a recently expired token to still be accepted for a brief window. This is useful when:

  • A request was in-flight when the token expired
  • Network latency causes the token to arrive at the server after expiration
  • Clock skew makes exact-time expiration unreliable

Implementation

const GRACE_PERIOD = 30; // 30 seconds

function verifyWithGracePeriod(token, secret) {
  try {
    // First try standard verification
    return jwt.verify(token, secret, { algorithms: ['HS256'] });
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      const expiredSecondsAgo = Math.floor(Date.now() / 1000) - err.expiredAt.getTime() / 1000;
      if (expiredSecondsAgo <= GRACE_PERIOD) {
        // Token expired within grace period — allow but flag for refresh
        const decoded = jwt.verify(token, secret, {
          algorithms: ['HS256'],
          ignoreExpiration: true
        });
        decoded._gracePeriodUsed = true;
        return decoded;
      }
    }
    throw err;
  }
}

Use grace periods sparingly — they expand the attack window. A 30-second grace period is usually sufficient.

Short-Lived vs Long-Lived Token Strategy

The most secure and practical approach combines short-lived access tokens with long-lived refresh tokens:

| Aspect | Access Token | Refresh Token |

|--------|-------------|---------------|

| Lifetime | 15 minutes | 7–30 days |

| On expiration | Silently refresh using refresh token | Force re-authentication |

| Storage | Memory (JavaScript variable) | HttpOnly secure cookie |

| Rotation | Not rotated | Rotated on each use |

This strategy ensures that even if an access token is compromised, it's only valid for 15 minutes. The refresh token provides seamless session continuity while being stored more securely. For implementation details, see our refresh token rotation guide and security best practices.

Conclusion

Token expiration is not just a security feature — it's a design decision that affects your entire authentication architecture. Use short-lived access tokens with the exp claim, handle clock skew with tolerance, refresh proactively on the client, and implement grace periods for edge cases. Use our JWT decoder to check the expiration claims in your tokens and verify they follow these best practices.

Need to decode a JWT token? Try our free JWT decoder tool — no sign-up required, runs entirely in your browser.