refresh token rotation

Introduction

Every time someone logs into an app and it magically “remembers” them for the next two weeks without asking for a password again, that’s token-based authentication doing its job quietly in the background.

But here’s the uncomfortable question most tutorials skip: what happens if that long-lived token gets stolen? If your answer is “nothing, it just keeps working for the thief too,” this article is for you.

We’re going to break down JWT authentication and refresh token rotation — the single most effective upgrade you can make to a token-based login system — in plain English, with diagrams, real code, and the mistakes that quietly get apps hacked.If you want a broader primer before diving into rotation specifically, Code condo has a solid rundown of JWT fundamentals worth skimming first.

Here’s an expanded version of your introduction with the focus keyword “refresh token rotation” naturally included multiple times while keeping it engaging and SEO-friendly:


Every time someone logs into an app and it magically “remembers” them for the next two weeks without asking for a password again, that’s token-based authentication doing its job quietly in the background. Modern web and mobile applications rely on JSON Web Tokens (JWTs) and refresh tokens to provide a seamless user experience while maintaining secure access.

But here’s the uncomfortable question most tutorials skip: what happens if that long-lived token gets stolen? If your answer is “nothing, it just keeps working for the thief too,” then your authentication system has a serious security gap. A compromised refresh token can allow attackers to generate new access tokens repeatedly, keeping unauthorized access alive long after the original login.

This is exactly why refresh token rotation has become one of the most important security practices in modern authentication systems. Instead of allowing the same refresh token to be reused indefinitely, refresh token rotation issues a brand-new refresh token every time the current one is used. The previous token is immediately invalidated, making stolen tokens far less useful to attackers and significantly reducing the risk of replay attacks.

Whether you’re building a REST API, a single-page application, or a mobile app, implementing refresh token rotation is no longer just an optional enhancement—it’s considered a security best practice. Many identity providers and OAuth 2.0 implementations now recommend refresh token rotation as a standard defense against token theft and session hijacking.

In this guide, we’ll break down JWT authentication and refresh token rotation in plain English. You’ll learn how access tokens and refresh tokens work together, why rotating refresh tokens dramatically improves security, how to implement refresh token rotation correctly, and which common mistakes developers make that leave applications vulnerable.

You’ll also see real-world authentication flows, practical code examples, security best practices, and techniques for detecting stolen tokens before they can be abused. By the end of this article, you’ll understand not only how JWT authentication works, but also why refresh token rotation is one of the simplest and most effective ways to build a secure authentication system.

If you want a broader primer before diving into refresh token rotation, Code Condo has a solid rundown of JWT fundamentals that’s worth skimming first.

TL;DR (for the impatient)

JWTs let your server verify a user without a database lookup. But long-lived refresh tokens are a juicy target — if stolen, an attacker stays logged in silently. Refresh token rotation fixes this: every time a refresh token is used, it’s swapped for a new one and the old one is burned. Reuse it, and the whole token family gets revoked. That’s the entire trick — the rest is implementation detail.


1. What Is JWT Authentication, Really?

JWT stands for JSON Web Token — a compact, signed piece of text that proves “this user is who they say they are” without your server needing to check a database on every single request.

Think of it like a wristband at a concert. Security checks your ID once at the gate, then straps on a wristband. For the rest of the night, nobody re-checks your ID — they just glance at the wristband. A JWT is that wristband, except it’s cryptographically signed so nobody can forge one.

A JWT is just three Base64-encoded chunks glued together with dots:

  • Header — which algorithm signed this token (e.g. HS256 or RS256)
  • Payload — the actual claims: user ID, role, expiry time (exp), issuer (iss)
  • Signature — a cryptographic hash that proves the header and payload weren’t tampered with

Common misconception: A JWT is signed, not encrypted. Anyone can paste your token into jwt.io and read the payload in plain text. Never put passwords, card numbers, or anything secret inside a JWT payload — treat it like a name tag, not a locked box.


2. Access Tokens vs. Refresh Tokens: The Trade-off

If you make a token live forever, it’s convenient but dangerous — steal it once, and an attacker has permanent access. If you make it expire every 2 minutes, it’s secure but users get logged out constantly and rage-quit your app.

The fix almost every production system uses is two tokens with two different lifespans — and it’s exactly what makes refresh token rotation possible in the first place:

Here’s an expanded, SEO-optimized version that naturally incorporates the focus keyword “refresh token rotation” multiple times without sounding repetitive:


2. Access Tokens vs. Refresh Tokens: The Trade-off

If you make a token live forever, it’s convenient but dangerous—steal it once, and an attacker has permanent access. If you make it expire every two minutes, it’s much more secure, but users get logged out constantly, interrupting their workflow and creating a frustrating experience.

Modern authentication systems solve this problem by using two different tokens with two different lifespans. This approach balances security with usability and forms the foundation of refresh token rotation.

An access token is designed to be short-lived. It typically expires within 5 to 30 minutes and is sent with every API request to prove the user’s identity. Because it has a limited lifespan, the damage caused by a stolen access token is restricted to a relatively short window.

A refresh token, on the other hand, is long-lived. Instead of accessing APIs directly, it is used only to request a new access token after the old one expires. This allows users to stay logged in for days or even weeks without repeatedly entering their credentials.

However, long-lived refresh tokens introduce a new security challenge. If an attacker steals a refresh token and it remains valid indefinitely, they can continue generating fresh access tokens and maintain unauthorized access for an extended period. This is where refresh token rotation becomes essential.

With refresh token rotation, the authentication server issues a brand-new refresh token every time the existing refresh token is used. At the same time, the previous refresh token is immediately revoked. This means every refresh token is intended for one-time use only. Even if a malicious actor steals an older token, it becomes useless once the legitimate user has already exchanged it.

This simple change dramatically improves security because refresh token rotation limits the value of stolen credentials and helps detect token replay attacks. If the server receives a refresh token that has already been used, it can recognize suspicious activity, revoke the affected session, and require the user to authenticate again.

Without refresh token rotation, a leaked refresh token can remain valid until its expiration date, giving attackers a long window to abuse it. With refresh token rotation, every successful token refresh invalidates the previous token, significantly reducing the opportunity for unauthorized access.

This combination of short-lived access tokens and one-time refresh tokens through refresh token rotation has become the recommended authentication strategy for modern web applications, mobile apps, and APIs because it provides both a smooth user experience and strong protection against token theft.

The relationship between these two tokens is illustrated below:

Access TokenRefresh Token
PurposeSent with every API requestUsed only to get a new access token
Lifespan5–15 minutesDays to weeks
Where it livesMemory / Authorization headerHttpOnly, Secure cookie
If stolenDamage window ≤ 15 minCould mean full account takeover
Rotates?Simply expires and is replacedShould rotate on every use (this article!)

3. The Problem: What If a Refresh Token Gets Stolen?

This is the scenario most tutorials gloss over. Refresh tokens usually live in an HttpOnly cookie, which protects them from JavaScript-based XSS attacks — but they can still leak through:

  • A misconfigured CORS policy or a compromised third-party script
  • A malicious browser extension reading cookies
  • Logs, error trackers, or analytics tools that accidentally capture request headers
  • A public/shared computer where “remember me” was left on

Without any extra protection, a stolen refresh token behaves exactly like the real user’s token, because it is one. The attacker can keep exchanging it for new access tokens indefinitely, staying logged in for as long as the token is valid, even after the real user changes their password on some setups.

The core question this article answers: If both the real user and an attacker now have a copy of the same refresh token, how does the server ever find out? Refresh token rotation is the answer, and it’s what the rest of this guide is built around.


4. Refresh Token Rotation — The Fix

Refresh token rotation is a simple rule: a refresh token can only ever be used once. The moment it’s used to get a new access token, the server issues a brand-new refresh token and permanently invalidates the old one.

That alone helps. But the real power move — the part most articles online skip entirely — is what you do when a used, already-rotated token shows up again. That single event is a massive red flag: it means two parties now have a copy of the same refresh token, and only one of them is legitimate.

This is called refresh token reuse detection, and it’s what turns “rotation” from a nice-to-have into a real security control.

Why “families” matter

Every refresh token that descends from the same original login is tagged with a shared family ID. When reuse is detected, you don’t just kill the one token — you kill every token that ever descended from that family. This guarantees that even if the attacker rotated the token a few times before the real user noticed, the entire chain collapses at once.


5. Why Refresh Token Rotation Is Worth the Extra Code

BenefitWhat it actually means
Stolen tokens self-destructA leaked refresh token is useless after its first use by anyone.
Theft becomes detectableReuse of a burned token is a reliable signal, not a guess.
Blast radius is containedRevoking a family logs out only that session’s lineage, not every user.
No extra login promptsUsers stay logged in seamlessly — security is invisible to them.

6. Where This Fits in Your Architecture

Before jumping into code, it helps to see the whole picture: the access token protects individual requests, while the refresh token rotation flow talks to a persistent store that remembers token state.

6. Where This Fits in Your Architecture

Before jumping into code, it helps to see the whole picture. In a modern authentication system, the access token protects individual API requests, while the refresh token rotation process works behind the scenes to maintain secure user sessions without requiring users to log in repeatedly.

Unlike access tokens, which are typically stateless and validated using their signature, refresh token rotation depends on a persistent data store that tracks the lifecycle of every refresh token. This database keeps information such as the token ID, user ID, expiration time, token family, revocation status, and whether the token has already been used. Maintaining this state is what enables refresh token rotation to invalidate old tokens and issue new ones securely.

Here’s how the architecture works in practice:

  1. The user authenticates with their credentials and receives a short-lived access token along with a refresh token.
  2. The access token is included with every API request until it expires.
  3. When the access token expires, the client sends the current refresh token to a dedicated refresh endpoint.
  4. The authentication server checks the database to verify that the refresh token is valid, active, and hasn’t been used before.
  5. If the token is valid, the server performs refresh token rotation by revoking the current refresh token, generating a brand-new refresh token, and issuing a new access token.
  6. The client replaces the old refresh token with the newly issued one and continues making authenticated requests.
  7. If an old refresh token is ever presented again, the server detects the replay attempt, blocks the request, and can revoke the entire token family to protect the user’s account.

This architecture ensures that refresh token rotation isn’t just generating new tokens—it is continuously verifying token integrity, detecting suspicious behavior, and preventing attackers from reusing compromised credentials.

Because refresh token rotation relies on server-side state, it’s common to store refresh token records in databases such as PostgreSQL, MySQL, Redis, or another secure persistence layer. Many production systems also hash refresh tokens before storing them, ensuring that even if the database is compromised, attackers cannot directly use the stored token values.

By combining stateless JWT access tokens with a stateful refresh token rotation mechanism, you get the best of both worlds: fast authentication for API requests and strong protection against token theft, replay attacks, and long-lived session hijacking. This architecture has become the recommended approach for securing modern web applications, mobile apps, and REST APIs.

Important nuance: Pure “stateless” JWT auth (no database at all) cannot support rotation or reuse detection — there’s nothing to check a token against. In practice, almost every serious JWT system keeps a small server-side record for refresh tokens, even though access tokens stay fully stateless. That’s a healthy, normal trade-off, not a failure of JWT. New to stateless auth? Code condo breaks down the basics before you dive into rotation.


7. Best Practices Checklist

Token lifetimes

  • Access tokens: 5–15 minutes. Short enough that a leak barely matters.
  • Refresh tokens: 7–30 days, with a hard “absolute” expiry regardless of rotation.

Storage

  • HttpOnly, Secure, SameSite=Strict cookies for refresh tokens — never localStorage or sessionStorage, which JavaScript (and any XSS payload) can read.
  • Keep access tokens in memory (a JS variable / React state), not in storage that persists across page reloads.

Refresh token rotation & reuse detection

  • Rotate the refresh token on every single use — no exceptions.
  • Track a family/lineage ID so a whole chain can be revoked at once.
  • On reuse of an already-used token, revoke the entire family and force re-login.

Signing & validation

  • Use RS256 (asymmetric) over HS256 when multiple services need to verify tokens — only one service should hold the private signing key.
  • Always validate exp, iss (issuer), and aud (audience) — not just the signature.
  • Never trust an unverified decoded payload. Decoding is not the same as verifying.

Operational hygiene

  • Give users a “log out of all devices” button that revokes every family tied to their account.
  • Rotate signing secrets periodically and support key rotation without breaking active sessions.
  • Log refresh-token reuse events — they’re one of the highest-signal security alerts you can wire up.

8. Step-by-Step Implementation (Node.js + Express)

Let’s build refresh token rotation for real. We’ll use Express, jsonwebtoken, and a simple database table to track refresh token families — the part most tutorials skip, and the part that actually makes rotation useful.

Quick reference: what each snippet does

StepFile / LocationPurpose
1TerminalInstall required packages
2SQL schemaDefine the refresh_tokens table that tracks families and hashes
3tokens.jsUtility functions to create and hash tokens
4routes/auth.js/auth/loginIssue the first access + refresh token pair
5routes/auth.js/auth/refreshRotate the refresh token and detect reuse (the core logic)
6Middleware — requireAuthProtect API routes using the access token
7routes/auth.js/auth/logoutRevoke an entire token family on logout

Step 1 — Install dependencies

npm install express jsonwebtoken cookie-parser bcrypt uuid

Step 2 — Design the refresh token table

This is the piece that separates “real” rotation from the toy in-memory examples floating around the internet. Store a hash of the token, not the raw token, exactly like you would a password.

-- refresh_tokens table
CREATE TABLE refresh_tokens (
  id           UUID PRIMARY KEY,
  user_id      UUID NOT NULL,
  family_id    UUID NOT NULL,      -- shared by every token in one login's lineage
  token_hash   TEXT NOT NULL,      -- SHA-256 of the raw token, never store it raw
  used         BOOLEAN DEFAULT FALSE,
  revoked      BOOLEAN DEFAULT FALSE,
  expires_at   TIMESTAMP NOT NULL,
  created_at   TIMESTAMP DEFAULT now()
);

Step 3 — Token creation utilities

// tokens.js
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

const ACCESS_SECRET  = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;

function createAccessToken(user) {
  return jwt.sign(
    { sub: user.id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: '15m', issuer: 'my-api', audience: 'my-app' }
  );
}

function createRefreshToken(user, familyId) {
  const jti = crypto.randomUUID();   // unique token id
  const token = jwt.sign(
    { sub: user.id, familyId, jti },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  return { token, jti };
}

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

module.exports = { createAccessToken, createRefreshToken, hashToken };

Step 4 — Login: issue the first token pair

// routes/auth.js
const crypto = require('crypto');
const { createAccessToken, createRefreshToken, hashToken } = require('../tokens');

app.post('/auth/login', async (req, res) => {
  const user = await verifyCredentials(req.body);   // your own password check
  if (!user) return res.status(401).json({ message: 'Invalid credentials' });

  const familyId = crypto.randomUUID();   // new lineage starts here
  const accessToken = createAccessToken(user);
  const { token: refreshToken, jti } = createRefreshToken(user, familyId);

  await db.refreshTokens.insert({
    id: jti,
    user_id: user.id,
    family_id: familyId,
    token_hash: hashToken(refreshToken),
    used: false,
    revoked: false,
    expires_at: addDays(new Date(), 7),
  });

  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });
  res.json({ accessToken });
});

Step 5 — The refresh endpoint (refresh token rotation + reuse detection)

This is the heart of the whole system. Read it carefully — the reuse check is what actually protects your users.

app.post('/auth/refresh', async (req, res) => {
  const oldToken = req.cookies.refresh_token;
  if (!oldToken) return res.status(401).json({ message: 'No refresh token' });

  let payload;
  try {
    payload = jwt.verify(oldToken, REFRESH_SECRET);
  } catch {
    return res.status(403).json({ message: 'Invalid refresh token' });
  }

  const record = await db.refreshTokens.findById(payload.jti);

  // --- REUSE DETECTION: this token was already rotated once before ---
  if (!record || record.revoked || record.token_hash !== hashToken(oldToken)) {
    return res.status(403).json({ message: 'Invalid refresh token' });
  }

  if (record.used) {
    // Someone is replaying a burned token -> assume the family is compromised
    await db.refreshTokens.revokeFamily(record.family_id);
    return res.status(403).json({ message: 'Session revoked — please log in again' });
  }

  // --- Legitimate rotation ---
  await db.refreshTokens.markUsed(record.id);

  const user = await db.users.findById(payload.sub);
  const accessToken = createAccessToken(user);
  const { token: newRefreshToken, jti } = createRefreshToken(user, record.family_id);

  await db.refreshTokens.insert({
    id: jti,
    user_id: user.id,
    family_id: record.family_id,   // same family — lineage continues
    token_hash: hashToken(newRefreshToken),
    used: false,
    revoked: false,
    expires_at: addDays(new Date(), 7),
  });

  res.cookie('refresh_token', newRefreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });
  res.json({ accessToken });
});

Step 6 — Protecting routes with the access token

function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header) return res.status(401).json({ message: 'Missing token' });

  const token = header.split(' ')[1];
  jwt.verify(token, ACCESS_SECRET, { issuer: 'my-api', audience: 'my-app' }, (err, decoded) => {
    if (err) return res.status(403).json({ message: 'Invalid or expired token' });
    req.user = decoded;
    next();
  });
}

app.get('/api/profile', requireAuth, (req, res) => {
  res.json({ userId: req.user.sub, role: req.user.role });
});

Step 7 — Logout: kill the whole family

app.post('/auth/logout', async (req, res) => {
  const token = req.cookies.refresh_token;
  if (token) {
    try {
      const { familyId } = jwt.verify(token, REFRESH_SECRET);
      await db.refreshTokens.revokeFamily(familyId);
    } catch {
      /* token already invalid, nothing to revoke */
    }
  }
  res.clearCookie('refresh_token');
  res.json({ message: 'Logged out' });
});

Why hash the refresh token before storing it? If your database is ever leaked, raw refresh tokens would let an attacker impersonate every user instantly. A SHA-256 hash gives you the same one-way protection you already use for passwords, at almost no extra cost.


9. Common Mistakes That Quietly Break Refresh Token Rotation

MistakeWhy it hurts
Storing refresh tokens in localStorageAny XSS bug can now read and steal them directly via JavaScript.
Using an in-memory Set to track used tokensResets on every server restart or deploy — protection silently disappears.
Rotating but not detecting reuseYou get new tokens, but a stolen one still works until it happens to expire.
No family/lineage trackingRevoking one token doesn’t stop the attacker’s already-rotated copy.
Long-lived access tokens “for convenience”Defeats the entire point — a stolen access token now works for hours or days.
Skipping issuer/audience checksA token meant for a different service or app may get accepted by mistake.

10. Frequently Asked Questions

Is JWT authentication stateless if I have to check a database for refresh tokens? Your access tokens stay fully stateless — that’s where JWT’s performance benefit lives. Only the much rarer refresh-token exchange touches the database, so you keep almost all the scalability benefit while gaining real revocation control.

Should I store the JWT secret in code? No. Keep it in environment variables at minimum, and in a secret manager (AWS Secrets Manager, HashiCorp Vault, etc.) for production systems. A hardcoded secret in a public repo is one of the most common real-world breach causes.

What’s the difference between logout and revoking a token family? A simple logout just clears the client-side cookie — the token could technically still be replayed if someone captured it earlier. Revoking the family invalidates it server-side too, which is what you want for a real “log out everywhere” button.

Can I use refresh token rotation with mobile apps, not just browsers? Yes — the same family/rotation logic applies. Mobile apps typically store the refresh token in secure OS-level storage (Keychain on iOS, Keystore on Android) instead of a cookie, but the server-side rotation and reuse-detection logic is identical.

HS256 or RS256 — which should I actually use? HS256 (symmetric) is simpler and fine for a single monolithic API. RS256 (asymmetric) is better once multiple services need to verify tokens, because only one service holds the private key used to sign them — the rest just verify with a public key.

Does refresh token rotation fully stop token theft? It doesn’t prevent theft, but it drastically limits the damage: a stolen token is useful for at most one exchange before reuse detection catches it. Pair it with short access token lifetimes and HTTPS everywhere for the strongest practical defense.


 refresh token rotation

Conclusion: Security That Users Never Notice

The best authentication system is one your users never think about — they log in once, stay logged in, and never see a security prompt they didn’t ask for. Refresh token rotation is what makes that possible without gambling on a long-lived token floating around forever.

The pattern is simple enough to remember in one line: short-lived access tokens, single-use refresh tokens, and a family ID that lets you burn the whole lineage the moment something looks wrong.

Everything else in this article — the database schema, the hashing, the reuse check — exists to make that one line actually true in production, not just on a whiteboard.

Next step: If you’re adding this to an existing app, start with rotation alone (swap the refresh token every time), ship it, then layer in reuse detection and family revocation once the basic flow is stable. Security improvements that ship beat perfect ones that don’t.

Read more: Eduonix has a hands-on course that walks through building secure authentication flows like this from scratch