Try the JWT Decoder

JWT Token Refresh Strategies: Access Tokens, Refresh Rotation, and When Sessions Beat JWTs

Short-lived access tokens require a refresh mechanism β€” and how you implement that mechanism determines your app's security and user experience. Here's refresh token rotation, silent refresh in SPAs, theft detection via refresh token families, and the cases where server-side sessions are simpler and safer.

By sadiqbd Β· June 13, 2026

Share:
JWT Token Refresh Strategies: Access Tokens, Refresh Rotation, and When Sessions Beat JWTs

A 15-minute access token solves the revocation problem β€” and creates the refresh problem

JWTs are stateless: the server validates the signature and trusts the claims without a database lookup. This is efficient, but it means a stolen token is valid until it expires. The standard mitigation is short-lived access tokens (15 minutes to 1 hour). The tradeoff: users would need to re-authenticate every 15 minutes β€” unacceptable UX.

Refresh tokens solve this: a long-lived token (days or weeks) is stored securely and used only to obtain new access tokens. The user stays logged in; each access token is short-lived; the damage window from a stolen access token is bounded.


The two-token pattern

Access token:

  • Short-lived: 15 minutes to 1 hour typical
  • Sent with every API request (Authorization: Bearer header)
  • Stateless β€” server validates signature only
  • If stolen: attacker has access for at most the token's remaining lifetime

Refresh token:

  • Long-lived: 7–30 days typical
  • Sent only to the token endpoint to get a new access token
  • Should be stored in HttpOnly, Secure, SameSite=Strict cookie (not localStorage)
  • Can be revoked: the server maintains a store of valid refresh tokens

The flow:

1. User logs in β†’ server returns access_token (15min) + refresh_token (7 days)
2. Client uses access_token for API calls
3. access_token expires β†’ client sends refresh_token to /auth/refresh
4. Server validates refresh_token, issues new access_token (+ optionally new refresh_token)
5. Client uses new access_token
6. User logs out β†’ server invalidates refresh_token

Refresh token rotation

Without rotation: the refresh token is static for its full lifetime. If stolen, the attacker can keep generating new access tokens indefinitely until the refresh token expires or is explicitly revoked.

With rotation: every time a refresh token is used, the server issues a new refresh token and immediately invalidates the old one. The client stores the new refresh token for the next cycle.

Client β†’ POST /auth/refresh { refresh_token: "rt_abc123" }
Server β†’ { access_token: "...", refresh_token: "rt_xyz789" }
       β†’ rt_abc123 is now INVALID; rt_xyz789 is the new valid token

The security benefit: a stolen refresh token has a narrow usage window. If the attacker uses it before the legitimate client, the legitimate client's next refresh attempt will fail (the token it holds has been rotated away). This signals a possible theft.


Refresh token families and theft detection

Refresh token families extend rotation to detect theft:

  1. On initial login, a "family" identifier is assigned to the refresh token chain
  2. Each rotation creates a new token in the same family
  3. If a previously-rotated (already-used) token is presented:
    • A legitimate client would never send an old token
    • This indicates the token was stolen and used by an attacker (or the legitimate client's copy was used by an attacker first)
    • Action: invalidate the entire family β€” log out all sessions using that family
# Pseudocode: refresh token validation
def refresh_token(token):
    stored = db.get_refresh_token(token)

    if not stored:
        # Token not in DB β€” either expired/revoked, or never existed
        raise InvalidTokenError

    if stored.used:
        # This token was already rotated β€” possible theft
        # Invalidate the entire family
        db.invalidate_family(stored.family_id)
        raise TokenReuseDetectedError("Possible token theft β€” all sessions invalidated")

    # Mark old token as used
    db.mark_used(token)

    # Issue new tokens in the same family
    new_refresh = generate_refresh_token(family_id=stored.family_id)
    new_access = generate_access_token(user_id=stored.user_id)

    db.store_refresh_token(new_refresh)
    return new_access, new_refresh

Silent refresh in Single Page Applications

SPAs run in the browser, which has no secure server-side session store. The standard approach:

Secure storage: store the refresh token in an HttpOnly cookie (not accessible via JavaScript). The cookie is sent automatically to the token refresh endpoint. Access tokens are stored in memory (JavaScript variable) β€” not localStorage, which is XSS-accessible.

Silent refresh flow:

// Token manager in SPA
let accessToken = null;
let refreshTimer = null;

async function silentRefresh() {
    // Cookie is sent automatically by the browser
    const response = await fetch('/auth/refresh', {
        method: 'POST',
        credentials: 'include'  // Include cookies
    });

    const { access_token, expires_in } = await response.json();
    accessToken = access_token;

    // Schedule next refresh before token expires (refresh 60s before expiry)
    const refreshIn = (expires_in - 60) * 1000;
    refreshTimer = setTimeout(silentRefresh, refreshIn);
}

The invisible-to-user result: the access token is refreshed in the background before it expires. Users never see an "expired session" error during active use β€” the session is maintained as long as the refresh token (in the cookie) is valid and the user continues using the app.


When server-side sessions beat JWTs

JWTs are often defaulted to when server-side sessions are a better fit. The cases where sessions win:

Immediate revocation is required: A JWT cannot be revoked before expiry without a blocklist (which requires a database lookup, eliminating the stateless advantage). If you need to immediately invalidate a session (suspicious activity, admin forced logout, user-initiated logout from all devices), server-side sessions are cleaner.

Monolithic architecture: Stateless JWTs shine when multiple services need to independently validate a token. In a monolith with a single database, a session lookup is fast, revocable, and simpler to implement correctly.

Sensitive operations with audit requirements: Server-side sessions produce clear audit trails. Every request hits the session store; activity can be tracked against session records. JWT-based systems require additional logging infrastructure to achieve the same.

Small teams moving quickly: JWT implementation has more failure modes (algorithm confusion, improper storage, missing expiry validation). Express-session or Django's built-in session framework is harder to misuse than a DIY JWT implementation.


JWT payload inspection for debugging

When a token-related error occurs in development, decoding the JWT immediately reveals:

{
  "sub": "usr_4821",
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com",
  "iat": 1731681600,
  "exp": 1731682500,
  "roles": ["user"],
  "scope": "read:profile write:settings"
}

Key things to check:

  • exp (expiry): has the token expired? Convert Unix timestamp to readable time
  • aud (audience): does it match the API the token is being sent to?
  • iss (issuer): is it from the expected identity provider?
  • scope / roles: does the token carry the permissions the API endpoint requires?

How to use the JWT Decoder on sadiqbd.com

  1. Paste any JWT (three dot-separated base64url segments)
  2. Inspect the header β€” algorithm used, token type
  3. Read the payload claims β€” expiry, issuer, audience, custom claims
  4. Convert timestamps β€” iat and exp are Unix timestamps; the tool converts them to readable dates
  5. Debug refresh errors β€” check whether a "token expired" error is actually an expired exp, an aud mismatch, or a wrong iss

Note: the JWT Decoder decodes the payload β€” it does not verify the signature. Signature verification requires the secret key and should only be done server-side.


Frequently Asked Questions

Can a JWT be used as a refresh token? Yes β€” some implementations use a JWT as the refresh token (with a longer expiry and a different audience claim, e.g. aud: "refresh"). The server validates it like any JWT but checks the audience is "refresh" and that it hasn't been rotated away. The advantage: no separate refresh token storage schema; the disadvantage: the "family" revocation pattern is harder to implement without a database.

Should refresh tokens be stored in localStorage? No β€” localStorage is accessible to any JavaScript on the page, including XSS-injected scripts. HttpOnly cookies are the recommended storage for refresh tokens in browser-based applications. Access tokens (which are short-lived) may be stored in memory (JavaScript variables) as a pragmatic trade-off.

Is the JWT Decoder free? Yes β€” completely free, no sign-up required.

Try the JWT Decoder free at sadiqbd.com β€” inspect any JWT header and payload, verify claims, and convert Unix timestamps to readable dates.

Share:
Try the related tool:
Open JWT Decoder

More JWT Decoder articles