background-shape
Refresh Tokens and Token Revocation
November 9, 2022 · 4 min read · by Muhammad Amal programming

TL;DR — Short access tokens (15 min), long refresh tokens (7-30 days). Refresh tokens MUST be revocable (server-side store). Rotate on each use. Detect reuse → revoke entire family. The pattern that makes JWT auth survive token theft.

After JWT for sessions is usually wrong, the related: when you do use JWT, refresh tokens are how you make it bearable.

The basic flow

  1. Client logs in → receives access_token (15 min) + refresh_token (30 days)
  2. Client uses access token until it expires
  3. Client uses refresh token to get new access token (and a new refresh token)
  4. Client uses new access token; old one is now invalid (or expired)
POST /login → {access_token, refresh_token}

GET /api (with access_token in Authorization)

[15 min later — access token expired]

POST /refresh (with refresh_token) → {new_access_token, new_refresh_token}

GET /api (with new_access_token)

The access token is short-lived = limited damage if leaked. The refresh token is long-lived but revocable.

Storage

Access token: JWT (stateless, fast verify).

Refresh token: opaque random string, stored server-side. Revocable.

CREATE TABLE refresh_tokens (
  id           text PRIMARY KEY,             -- the token itself (or hash)
  user_id      bigint NOT NULL,
  family_id    text NOT NULL,                -- for rotation/reuse detection
  issued_at    timestamptz NOT NULL DEFAULT now(),
  expires_at   timestamptz NOT NULL,
  used         boolean NOT NULL DEFAULT false,
  parent_id    text                          -- the refresh token this was generated from
);

CREATE INDEX ON refresh_tokens (user_id);
CREATE INDEX ON refresh_tokens (family_id);

For high security, store the SHA-256 hash of the token, not the token itself. Compromise of the DB doesn’t give attackers usable tokens.

Rotation on each use

Each refresh exchange returns a NEW refresh token. The old one is marked used; using it again is suspicious.

def refresh(old_refresh: str) -> tuple[str, str]:
    row = db.execute("""
        SELECT * FROM refresh_tokens
        WHERE id = :id AND used = false AND expires_at > now()
    """, {"id": hash(old_refresh)}).fetchone()

    if not row:
        # Either expired, used, or never existed
        # If it WAS valid before but now used = reuse attack
        check_for_reuse(old_refresh)
        raise Unauthorized()

    # Mark used
    db.execute("UPDATE refresh_tokens SET used = true WHERE id = :id",
               {"id": hash(old_refresh)})

    # Issue new
    new_refresh = secrets.token_urlsafe(48)
    db.execute("""
        INSERT INTO refresh_tokens (id, user_id, family_id, expires_at, parent_id)
        VALUES (:id, :uid, :fid, :exp, :parent)
    """, {
        "id": hash(new_refresh),
        "uid": row.user_id,
        "fid": row.family_id,         # same family
        "exp": now + timedelta(days=30),
        "parent": row.id,
    })

    new_access = issue_access_token(row.user_id)
    return new_access, new_refresh

Used tokens are kept (not deleted) for reuse detection.

Reuse detection — the security win

If an attacker steals a refresh token AND uses it:

  • Attacker gets new access + refresh
  • Legitimate user later tries to refresh with OLD token
  • Server sees: “this token was already used; alarm”
  • Server invalidates ALL refresh tokens in the family (legitimate user’s session AND attacker’s)
def check_for_reuse(token: str):
    row = db.execute("""
        SELECT family_id FROM refresh_tokens WHERE id = :id AND used = true
    """, {"id": hash(token)}).fetchone()
    if row:
        # Reuse detected — revoke whole family
        db.execute("""
            UPDATE refresh_tokens SET used = true
            WHERE family_id = :fid
        """, {"fid": row.family_id})
        alert_security_team(row)

Either the attacker is locked out, or the legitimate user has to log in again (mild annoyance, real protection).

Revocation

For “log out all sessions” or “user changed password”:

UPDATE refresh_tokens SET used = true
WHERE user_id = $1;

All refresh tokens for that user invalidated. Access tokens still work until expiry (max 15 min), but no refresh = no extension.

For “log out everywhere right now”: couple with a short-lived denylist for active access tokens. Or accept the 15-minute window.

Storage of refresh tokens client-side

Web app:

  • httpOnly cookie (preferred — XSS-safe)
  • localStorage (XSS-vulnerable but works without server cookie domain)

Native app:

  • iOS Keychain / Android KeyStore (preferred)
  • Encrypted SharedPreferences as fallback

Never:

  • localStorage on web for sensitive apps (XSS reads it)
  • Plaintext file
  • LocalStorage of native app without OS keychain

Sliding window vs fixed window

Fixed window: refresh token expires 30 days after issue, no matter how many refreshes.

Sliding window: refresh token’s expiry extends with each use.

Both valid. Fixed is more secure (eventually re-login forced). Sliding is friendlier UX.

I default to fixed with 30-day window. Users re-authenticate monthly. Acceptable for most consumer apps.

When NOT to use refresh tokens

For first-party web apps with single-domain cookies: sessions are simpler. Refresh tokens are for:

  • Mobile apps (no cookie domain)
  • SPAs talking to multiple APIs across domains
  • Federated auth where the API doesn’t have a DB
  • Anywhere JWT is the right choice (see JWT for sessions)

Common Pitfalls

No rotation. Token stolen = forever-valid until expiry. Rotate on each use.

Storing refresh tokens in JWT. Defeats revocation. Refresh tokens are opaque + server-side.

No reuse detection. Most of the security value comes from this.

Refresh tokens that never expire. Forever-grant. Set a max lifetime.

Access tokens as long as refresh tokens. Defeats the whole point. Access tokens must be short.

Storing tokens in localStorage on web. XSS-readable. httpOnly cookies safer.

No revocation on password change. New password = old session should die.

Wrapping Up

Short JWT access + long opaque refresh + rotation + reuse detection = JWT auth that survives token theft. Friday: OAuth 2.1 vs 2.0.