JWT Refresh Token Implementation — The Right Way in 2026

I spent three months untangling a refresh token mess at a healthcare startup. The original implementation had no rotation, no expiration on refresh tokens, and stored them in a single database table without indexes. When we finally audited the token store, we found 14 million active refresh tokens — most from users who hadn't logged in for over a year. The database query to find a matching refresh token took eight seconds. The auth endpoint was timing out. Every login failed.

That implementation was technically "working" in the sense that tokens were being issued. But it violated every security and performance principle of refresh token design. It took us two rewrites to get it right.

This guide covers what I learned from that mess and from implementing refresh token systems at three startups since. It includes production-grade patterns, not toy examples.


What a Refresh Token Actually Is

A refresh token is a long-lived credential used to obtain new access tokens without requiring the user to re-authenticate. The core idea is simple: access tokens live short (15–60 minutes), refresh tokens live longer (days to months), and the refresh token is used exclusively through a secure backend endpoint.

The threat model looks like this:

  • Access token leaked? It expires in under an hour. Limited damage window.
  • Refresh token leaked? Bad, but rotation and revocation limit the blast radius.

This two-token architecture is the foundation of OAuth 2.0 and most modern auth systems. The access token goes everywhere — HTTP headers, third-party API calls, service-to-service requests. The refresh token stays close to home, typically stored securely in the browser and sent only to your own token endpoint.


The Naive Implementation (Don't Do This)

Every developer starts somewhere. Here is the version I see in code reviews that needs to be rewritten:

// BAD: Naive refresh token implementation
const jwt = require('jsonwebtoken');

// No rotation — same refresh token forever
const refreshToken = jwt.sign(
  { userId: user.id },
  REFRESH_SECRET,
  { expiresIn: '30d' }
);

// Stored without hashing — plaintext in database
await db.query(
  'INSERT INTO refresh_tokens (user_id, token) VALUES (?, ?)',
  [user.id, refreshToken]
);

This has three fatal problems:

  1. No rotation. The same refresh token is valid for 30 days. If it leaks, the attacker has a month of access.
  2. Plaintext storage. The database contains the exact token string. Anyone with DB read access — a compromised backup, a rogue DBA, a SQL injection — steals every session.
  3. No revocation mechanism. You cannot invalidate a specific session without invalidating all sessions or deleting rows manually.

Let me walk through each problem and its fix.


Problem 1: Refresh Token Rotation

Rotation means every time a refresh token is used, the server invalidates the old one and issues a new one. This is not optional — it is required by OAuth 2.0 security best practices and every major identity provider.

How Rotation Works

const crypto = require('crypto');

function generateRefreshToken() {
  // Use cryptographic randomness, not JWT
  return crypto.randomBytes(40).toString('hex');
}

async function rotateRefreshToken(oldTokenHash, userId) {
  // 1. Validate the old token (check it exists and isn't revoked)
  const storedToken = await db.query(
    'SELECT id, expires_at FROM refresh_tokens WHERE token_hash = ? AND revoked = FALSE',
    [oldTokenHash]
  );

  if (!storedToken || storedToken.expires_at < new Date()) {
    throw new Error('Invalid or expired refresh token');
  }

  // 2. Revoke the old token
  await db.query(
    'UPDATE refresh_tokens SET revoked = TRUE WHERE id = ?',
    [storedToken.id]
  );

  // 3. Issue a new one
  const newToken = generateRefreshToken();
  const newTokenHash = crypto.createHash('sha256').update(newToken).digest('hex');

  await db.query(
    'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)',
    [userId, newTokenHash, new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)]
  );

  return newToken;
}

The critical design decision here: we store a SHA-256 hash of the refresh token, not the token itself. When the client sends the token, we hash it and look up the hash. This means a database breach does not expose active refresh tokens.

The Rotation Flow

1. Client sends refresh_token to /token endpoint
2. Server hashes the received token
3. Server looks up hash in DB, validates it's not revoked
4. Server revokes old token (sets revoked = TRUE)
5. Server generates new refresh_token and hashes it
6. Server stores new hash in DB
7. Server returns new access_token + refresh_token
8. Client replaces stored refresh token

If an attacker intercepts a refresh token and uses it before the legitimate client, the legitimate client's next refresh attempt will fail because the token was already rotated. That failure is your intrusion detection signal.


Problem 2: Token Binding and Reuse Detection

Rotation creates a detection opportunity. When a refresh token is rotated and the old token gets used again, it means either:

  • The legitimate client had a race condition (rare with proper locking)
  • Someone else has the token (likely an attacker)

Implementing Reuse Detection

async function rotateWithReuseDetection(oldTokenHash, clientId) {
  return await db.transaction(async (trx) => {
    // Lock the token row for update
    const storedToken = await trx(
      'SELECT id, user_id, revoked, family FROM refresh_tokens WHERE token_hash = ? FOR UPDATE',
      [oldTokenHash]
    );

    if (!storedToken) {
      throw new Error('Invalid refresh token');
    }

    if (storedToken.revoked) {
      // Reuse detected! Someone is trying to use an already-rotated token.
      // This means the token family is compromised.
      await revokeTokenFamily(storedToken.family, trx);
      throw new Error('Token reuse detected — family revoked');
    }

    // Mark current token as revoked (rotate)
    await trx(
      'UPDATE refresh_tokens SET revoked = TRUE WHERE id = ?',
      [storedToken.id]
    );

    // Issue new token in same family
    const newToken = generateRefreshToken();
    const newTokenHash = crypto.createHash('sha256').update(newToken).digest('hex');

    await trx(
      'INSERT INTO refresh_tokens (user_id, token_hash, family, expires_at) VALUES (?, ?, ?, ?)',
      [storedToken.user_id, newTokenHash, storedToken.family, new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)]
    );

    return newToken;
  });
}

The family field is a UUID generated when the first refresh token in a chain is created. All rotated tokens share the same family ID. If we detect reuse, we revoke the entire family — every session the attacker might have captured.

Race Condition Handling

In high-concurrency environments, two legitimate refresh requests might arrive simultaneously. Use database transactions with row-level locking to ensure only one rotation succeeds. The second request will find the token already revoked and can be rejected with a clear error message — the client should simply re-authenticate.


Problem 3: Revocation Strategies

Rotation handles automatic revocation on use. But you also need explicit revocation for:

  • User logs out
  • Password change
  • Account suspension
  • Device removal

Session-Based Revocation

The simplest approach: store a revoked timestamp and a revoked_reason column. When a user logs out, mark all their refresh tokens as revoked.

async function revokeAllUserSessions(userId) {
  await db.query(
    `UPDATE refresh_tokens
     SET revoked = TRUE, revoked_at = NOW(), revoked_reason = 'logout'
     WHERE user_id = ? AND revoked = FALSE AND expires_at > NOW()`,
    [userId]
  );
}

async function revokeSpecificSession(refreshTokenHash) {
  await db.query(
    `UPDATE refresh_tokens
     SET revoked = TRUE, revoked_at = NOW(), revoked_reason = 'user_action'
     WHERE token_hash = ? AND revoked = FALSE`,
    [refreshTokenHash]
  );
}

Token Version Claims

An alternative approach stores a token_version integer in the user's database record and as a custom claim in the access token:

// Issuing access token
const accessToken = jwt.sign(
  {
    sub: user.id,
    tokenVersion: user.token_version,
  },
  ACCESS_SECRET,
  { expiresIn: '15m' }
);

// Verifying access token
function verifyAccessToken(token) {
  const payload = jwt.verify(token, ACCESS_SECRET);

  // Check token version hasn't been bumped
  // This requires a DB lookup, so cache aggressively
  if (payload.tokenVersion !== getCachedUserTokenVersion(payload.sub)) {
    throw new Error('Token version invalidated');
  }

  return payload;
}

Increment token_version on password changes or "log out all devices." Every existing access token becomes invalid immediately, even if it hasn't expired. This is the nuclear option — use it selectively.


Complete Implementation (Node.js)

Here is a production-ready refresh token service:

const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');

class RefreshTokenService {
  constructor(db) {
    this.db = db;
    this.rotationLock = new Map();
  }

  generateToken() {
    return crypto.randomBytes(40).toString('hex');
  }

  hashToken(token) {
    return crypto.createHash('sha256').update(token).digest('hex');
  }

  async issue(userId, ttlMs = 30 * 24 * 60 * 60 * 1000) {
    const token = this.generateToken();
    const hash = this.hashToken(token);
    const family = uuidv4();

    await this.db.query(
      `INSERT INTO refresh_tokens (user_id, token_hash, family, expires_at)
       VALUES (?, ?, ?, ?)`,
      [userId, hash, family, new Date(Date.now() + ttlMs)]
    );

    return { token, expiresAt: Date.now() + ttlMs };
  }

  async refresh(oldToken) {
    const oldHash = this.hashToken(oldToken);

    return await this.db.transaction(async (trx) => {
      const rows = await trx.query(
        `SELECT id, user_id, family, revoked, expires_at
         FROM refresh_tokens
         WHERE token_hash = ? FOR UPDATE`,
        [oldHash]
      );

      if (rows.length === 0) {
        throw new Error('REFRESH_TOKEN_INVALID');
      }

      const storedToken = rows[0];

      if (storedToken.expires_at < new Date()) {
        throw new Error('REFRESH_TOKEN_EXPIRED');
      }

      if (storedToken.revoked) {
        // Reuse detected — revoke the entire token family
        await trx.query(
          `UPDATE refresh_tokens SET revoked = TRUE, revoked_reason = 'reuse_detected'
           WHERE family = ? AND revoked = FALSE`,
          [storedToken.family]
        );
        throw new Error('REFRESH_TOKEN_REUSE_DETECTED');
      }

      // Revoke the old token
      await trx.query(
        `UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW()
         WHERE id = ?`,
        [storedToken.id]
      );

      // Issue a new one
      const newToken = this.generateToken();
      const newHash = this.hashToken(newToken);

      await trx.query(
        `INSERT INTO refresh_tokens (user_id, token_hash, family, expires_at)
         VALUES (?, ?, ?, ?)`,
        [storedToken.user_id, newHash, storedToken.family, new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)]
      );

      return { token: newToken, userId: storedToken.user_id };
    });
  }

  async revoke(token) {
    const hash = this.hashToken(token);
    await this.db.query(
      `UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW(), revoked_reason = 'explicit'
       WHERE token_hash = ? AND revoked = FALSE`,
      [hash]
    );
  }

  async revokeAllForUser(userId) {
    await this.db.query(
      `UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW(), revoked_reason = 'logout_all'
       WHERE user_id = ? AND revoked = FALSE AND expires_at > NOW()`,
      [userId]
    );
  }

  async cleanup() {
    // Remove expired tokens older than 90 days
    await this.db.query(
      `DELETE FROM refresh_tokens WHERE expires_at < DATE_SUB(NOW(), INTERVAL 90 DAY)`
    );
  }
}

Database Schema

CREATE TABLE refresh_tokens (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT NOT NULL,
  token_hash CHAR(64) NOT NULL UNIQUE,
  family CHAR(36) NOT NULL,
  revoked BOOLEAN DEFAULT FALSE,
  revoked_reason VARCHAR(50) DEFAULT NULL,
  revoked_at DATETIME DEFAULT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  expires_at DATETIME NOT NULL,
  INDEX idx_user_id (user_id),
  INDEX idx_family (family),
  INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB;

The UNIQUE constraint on token_hash prevents accidental double-insertion. The index on family supports the reuse detection query.


Complete Implementation (Python / FastAPI)

import hashlib
import secrets
import uuid
from datetime import datetime, timedelta
from typing import Optional, Tuple

class RefreshTokenService:
    def __init__(self, db):
        self.db = db

    def generate_token(self) -> str:
        return secrets.token_hex(40)

    def hash_token(self, token: str) -> str:
        return hashlib.sha256(token.encode()).hexdigest()

    async def issue(self, user_id: int, ttl_days: int = 30) -> Tuple[str, datetime]:
        token = self.generate_token()
        token_hash = self.hash_token(token)
        family = str(uuid.uuid4())
        expires_at = datetime.utcnow() + timedelta(days=ttl_days)

        await self.db.execute(
            """INSERT INTO refresh_tokens (user_id, token_hash, family, expires_at)
               VALUES ($1, $2, $3, $4)""",
            user_id, token_hash, family, expires_at
        )

        return token, expires_at

    async def refresh(self, old_token: str) -> Tuple[str, int]:
        old_hash = self.hash_token(old_token)

        async with self.db.transaction():
            row = await self.db.fetchrow(
                """SELECT id, user_id, family, revoked, expires_at
                   FROM refresh_tokens
                   WHERE token_hash = $1
                   FOR UPDATE""",
                old_hash
            )

            if not row:
                raise ValueError("Invalid refresh token")

            if row["expires_at"] < datetime.utcnow():
                raise ValueError("Refresh token expired")

            if row["revoked"]:
                # Reuse detected — revoke entire family
                await self.db.execute(
                    """UPDATE refresh_tokens
                       SET revoked = TRUE, revoked_reason = 'reuse_detected'
                       WHERE family = $1 AND revoked = FALSE""",
                    row["family"]
                )
                raise ValueError("Token reuse detected")

            # Revoke old token
            await self.db.execute(
                """UPDATE refresh_tokens
                   SET revoked = TRUE, revoked_at = NOW()
                   WHERE id = $1""",
                row["id"]
            )

            # Issue new token
            new_token = self.generate_token()
            new_hash = self.hash_token(new_token)
            expires_at = datetime.utcnow() + timedelta(days=30)

            await self.db.execute(
                """INSERT INTO refresh_tokens (user_id, token_hash, family, expires_at)
                   VALUES ($1, $2, $3, $4)""",
                row["user_id"], new_hash, row["family"], expires_at
            )

            return new_token, row["user_id"]

    async def revoke_all_for_user(self, user_id: int):
        await self.db.execute(
            """UPDATE refresh_tokens
               SET revoked = TRUE, revoked_at = NOW(), revoked_reason = 'logout_all'
               WHERE user_id = $1 AND revoked = FALSE AND expires_at > NOW()""",
            user_id
        )

Where to Store Refresh Tokens

Refresh tokens must be stored more securely than access tokens because they live longer and grant more power.

Browser (SPA)

Use an httpOnly, Secure, SameSite=Strict cookie for the refresh token:

// Server-side cookie setting
res.cookie('refresh_token', refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  path: '/api/auth',
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});

The path: '/api/auth' restriction means only the auth endpoint can read the cookie. An XSS attack on another page cannot send the refresh token anywhere useful.

Mobile Apps

Use the platform's secure storage (iOS Keychain, Android EncryptedSharedPreferences). Never store refresh tokens in plain SharedPreferences, UserDefaults, or app-visible files.

Backend Services (Machine-to-Machine)

Store refresh tokens in a secrets manager (AWS Secrets Manager, HashiCorp Vault) or encrypted at rest in the service's database. Use environment variables with restricted access for CI/CD.


Common Refresh Token Mistakes

Mistake 1: Using JWT as Refresh Token

JWTs are a poor format for refresh tokens. They cannot be revoked easily (unless you maintain a blocklist, which defeats the purpose). They encode claims that become stale. Use opaque random tokens instead — they are simpler, more secure, and easier to revoke. See JWT Access Token vs Refresh Token for a full comparison of the two token types.

Mistake 2: No Expiration on Refresh Tokens

I have seen refresh tokens with no expiration. These are ticking time bombs. If one leaks, the attacker has indefinite access. Always set an expiration. Even 90 days is long enough for most applications.

Mistake 3: Storing Refresh Tokens in localStorage

This is the most common mistake in SPAs. localStorage is accessible to any JavaScript running on the same origin. One XSS vulnerability and every refresh token is exfiltrated. For a deeper analysis of the risks, see Is It Safe to Store JWT in localStorage?.

Mistake 4: Silent Rotation Failures

When rotation fails, some implementations silently fall back to the old token. This defeats the purpose. If rotation fails — network error, DB timeout, reuse detected — the client should get a clear error and be forced to re-authenticate.

Mistake 5: Not Rate-Limiting the Token Endpoint

The /auth/token endpoint is a prime target for brute-force attacks. Rate-limit per IP, per user, and per token family.

// Rate limit: 5 refresh attempts per minute per user
const rateLimitKey = `refresh:${userId}`;
const attempts = await redis.incr(rateLimitKey);
if (attempts === 1) {
  await redis.expire(rateLimitKey, 60);
}
if (attempts > 5) {
  throw new Error('TOO_MANY_REQUESTS');
}

The Access Token Side

Refresh tokens are only half the system. Access tokens need their own considerations. They should:

  • Be JWTs (compact, self-contained, easily verified)
  • Expire in 15 minutes or less
  • Contain only the claims needed for authorization
  • Never contain secrets or sensitive data

The interplay between short-lived access tokens and rotated refresh tokens is what makes the system secure. The access token is verified on every request without touching a database. The refresh token is rarely used and heavily protected. Both are necessary.


FAQ

Why should refresh tokens be rotated?

Rotation limits the window of exposure. If a refresh token is compromised and the attacker uses it, the legitimate user's next refresh attempt fails — alerting both the user and the system to the breach. Without rotation, a compromised refresh token remains valid for its entire lifetime.

Should refresh tokens be JWTs?

No. Refresh tokens should be opaque random strings (cryptographically random bytes). JWTs encode claims that become stale and cannot be revoked server-side without additional infrastructure. Opaque tokens are simpler, shorter, and force a database lookup that enables revocation.

How long should refresh tokens live?

30 days is the sweet spot for most consumer applications. Enterprise applications often use 7–14 days. The tradeoff is user convenience (longer is better) versus security (shorter limits exposure). Allow the user to check "Remember this device" to extend the lifetime.

Can refresh tokens be revoked?

Yes — that is the entire point. Store a hash of the refresh token in a database with a revoked column. On logout, set the flag. On refresh, check the flag. This is why you use opaque tokens instead of JWTs for refresh.

What happens when reuse is detected?

Revoke the entire token family (all tokens sharing the same family UUID). Force the user to re-authenticate. Log the event for security monitoring. If the reuse happens repeatedly, consider locking the account and notifying the user.

Should access tokens also be stored in a database?

No — that defeats the stateless nature of JWTs. Access tokens should be verified cryptographically using the signature alone. The database is only involved for refresh token validation and revocation.

How do I handle refresh token race conditions?

Use database transactions with row-level locking (SELECT ... FOR UPDATE). The first concurrent request acquires the lock and rotates the token. The second request finds the token already revoked and returns an error. The client handles this by retrying with the new token.

Is it safe to send refresh tokens over HTTPS?

Yes. If your HTTPS configuration is correct (TLS 1.2+, proper certificate validation, no mixed content), the refresh token is encrypted in transit. The primary threat is at rest — in the browser's storage, in server logs, or in the database.


Summary

Refresh token implementation is not complex, but it requires attention to detail. The critical rules:

  1. Use opaque random tokens, not JWTs, for refresh
  2. Store hashed tokens in the database, never plaintext
  3. Rotate every refresh token on each use
  4. Implement reuse detection to catch token theft
  5. Use httpOnly cookies for browser storage
  6. Rate-limit the token endpoint
  7. Clean up expired tokens regularly

A well-implemented refresh token system is invisible to users and boring for operations. It just works. A poorly implemented one causes production incidents, security breaches, and late-night debugging sessions. The difference is a few hours of careful design upfront.

If you need to inspect or debug JWT tokens during development or incident response, the JWT Decoder decodes and verifies tokens instantly — it has saved me countless hours when tracking down auth flow bugs.