Refresh Tokens Done Right
- jwt
- refresh-tokens
- nestjs
- authentication
- security
- nodejs
- typescript
- saas
In JWT Authentication in NestJS I covered issuing and verifying access tokens: signing a JWT, putting a guard in front of your routes, and reading the claims off req.user. That article deliberately skipped one question: what happens when the access token expires? Answering it properly is the difference between an auth system that feels solid and one that quietly leaks sessions or locks users out mid-workflow.
This is part of the Full Stack SaaS Masterclass, a build-it-for-real series that takes a multi-tenant SaaS from an empty folder to production. Module 2 is authentication, and refresh tokens are the piece that makes short-lived access tokens usable without punishing your users.
Short-lived JWTs are the right call for access tokens, but they only work if you have a clean story for renewing them. Get the refresh flow wrong and you either end up with sessions that never really expire, or a system so aggressive about revocation that a network hiccup logs someone out of a form they were halfway through filling.
Why access tokens alone don't work
A JWT access token is stateless by design. Once you sign it, the server doesn't need to look anything up to verify it; the signature does the work. That's the whole appeal: no database round trip on every request, no session store to keep warm. It scales horizontally without any extra effort on your part.
The tradeoff is that a stateless token can't be revoked. If you set a fifteen-minute expiry, an attacker with a stolen token has a fifteen-minute window regardless of what you do server-side. Set it to a week to reduce friction, and now a stolen token is valid for a week. There's no in-between where you get both statelessness and instant revocation. You have to pick.
The refresh token pattern resolves this by splitting the problem. The access token stays short-lived, typically 10 to 15 minutes, and does the stateless verification work on every request. A separate refresh token, which is opaque or a JWT with a much longer lifetime, lives specifically to mint new access tokens. Because refresh tokens are used far less often, checking them against a database or Redis on each use is cheap. That's where you get your revocation story back.
The refresh flow, end to end
The sequence is straightforward once you separate the two tokens' jobs:
- User logs in. The server issues a short-lived access token and a longer-lived refresh token.
- The client stores both and sends the access token on every authenticated request.
- When the access token expires, the client calls a dedicated refresh endpoint with the refresh token.
- The server validates the refresh token against its store, issues a new access token (and usually a new refresh token), and the client keeps going.
- If the refresh token is invalid, expired, or already used, the client is forced to log in again.
The part people get wrong is step 4. If you reissue the same refresh token every time, you've built a token that's effectively permanent as long as it's used periodically, which defeats the purpose. Rotate it on every use instead: each refresh call invalidates the old refresh token and issues a brand new one. This is called refresh token rotation, and it's what turns a "long-lived secret" into something you can actually reason about.
Storing and rotating refresh tokens
Don't store refresh tokens as raw JWTs you trust blindly. Store a record of each one server-side so you can revoke, audit, and detect reuse. A hashed value in Postgres works well, since you're checking it infrequently and want durability across restarts.
create table refresh_tokens (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
family_id uuid not null,
token_hash text not null,
expires_at timestamptz not null,
revoked_at timestamptz,
replaced_by uuid references refresh_tokens(id),
created_at timestamptz not null default now()
);
create index idx_refresh_tokens_user_id on refresh_tokens(user_id);
create index idx_refresh_tokens_token_hash on refresh_tokens(token_hash);
The family_id groups every refresh token descended from a single login. Every rotation within that login produces a new row pointing back at the one it replaced, but they all share the same family_id. That grouping is what makes reuse detection possible, which I'll get to in the next section.
Here's a NestJS service that issues and rotates tokens against that table:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { randomUUID, createHash } from 'crypto';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RefreshToken } from './refresh-token.entity';
const REFRESH_TOKEN_TTL_DAYS = 30;
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
@Injectable()
export class TokenService {
constructor(
private readonly jwtService: JwtService,
@InjectRepository(RefreshToken)
private readonly refreshTokens: Repository<RefreshToken>,
) {}
async issueTokenPair(userId: string, familyId: string = randomUUID()) {
const rawRefreshToken = randomUUID() + randomUUID();
const expiresAt = new Date(
Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
);
const record = await this.refreshTokens.save({
userId,
familyId,
tokenHash: hashToken(rawRefreshToken),
expiresAt,
});
const accessToken = this.jwtService.sign(
{ sub: userId },
{ expiresIn: '15m' },
);
return { accessToken, refreshToken: rawRefreshToken, recordId: record.id };
}
async rotate(rawRefreshToken: string) {
const tokenHash = hashToken(rawRefreshToken);
const existing = await this.refreshTokens.findOne({
where: { tokenHash },
});
if (!existing || existing.expiresAt < new Date()) {
throw new UnauthorizedException('Refresh token invalid or expired');
}
if (existing.revokedAt) {
await this.revokeFamily(existing.familyId);
throw new UnauthorizedException('Refresh token reuse detected');
}
const { accessToken, refreshToken, recordId } = await this.issueTokenPair(
existing.userId,
existing.familyId,
);
existing.revokedAt = new Date();
existing.replacedBy = recordId;
await this.refreshTokens.save(existing);
return { accessToken, refreshToken };
}
async revokeFamily(familyId: string) {
await this.refreshTokens.update(
{ familyId, revokedAt: null },
{ revokedAt: new Date() },
);
}
}
The refresh token here is a random opaque string, not a signed JWT. There's no reason to make it a JWT: the server always looks it up in the database anyway, so the self-verifying property of a JWT buys you nothing and just adds bytes to the payload.
Detecting reuse: your best signal for token theft
The existing.revokedAt check in rotate() is the whole point of rotation. Under normal use, a client uses a refresh token exactly once, gets a new one back, and the old one is marked revoked. If that same old, revoked token shows up again, one of two things happened: a client retried a request it shouldn't have, or someone stole the refresh token and is now racing the legitimate client to use it.
You can't tell those two cases apart from the request alone, so treat both the same way: revoke the entire token family. That logs out every session descended from that original login, on every device, immediately. It's a blunt instrument, but it's the correct one. A stolen refresh token used once means a compromised session, and the safe assumption is that the whole chain is compromised, down to every token that came out of that original login.
This is also why the family_id matters. Without it, you can only revoke the one token you're looking at, and an attacker who's ahead of the legitimate client in the rotation race keeps a live session while the real user gets logged out. Grouping by family and revoking the whole thing closes that gap.
Where to store tokens on the client
For a web app, the access token can live in memory (a variable in your React state or a module-level store), since it's short-lived and only needed for the current session. The refresh token is the one worth protecting, and an httpOnly, secure, sameSite cookie is the right default. JavaScript can't read it, which closes off the most common XSS-driven token theft path. The tradeoff is that cookies bring CSRF into scope. Pair this with a CSRF token, or, if your API and frontend share an origin and you're using sameSite=strict or lax appropriately, accept the reduced but nonzero CSRF surface and mitigate it at the endpoint level.
localStorage is the wrong home for a refresh token. It's readable by any script running on the page, and a single vulnerable dependency in your frontend bundle turns into a session-hijacking bug. I'd rather deal with CSRF, which has well-understood mitigations, than XSS-driven token theft, which is harder to fully rule out in a large frontend codebase.
Mobile apps are a different story: refresh tokens there typically live in the platform keychain (iOS Keychain, Android Keystore), which is a reasonably secure, OS-managed store designed for exactly this.
Production pitfalls
A few things that look fine in a demo and break under real traffic:
Concurrent refresh requests. If a client fires two requests that both hit 401 at the same time, both might try to refresh simultaneously. With naive rotation, the second call sees an already-revoked token and gets treated as reuse, logging the user out incorrectly. Debounce refresh calls on the client so only one is in flight at a time, and queue anything else waiting on it.
Clock skew between services. If your access token expiry check and refresh token expiry check run on servers with drifting clocks, you can get tokens that expire earlier or later than intended. Not usually catastrophic, but worth accounting for with a small grace window rather than assuming perfect synchronization.
Forgetting to clean up expired rows. The refresh_tokens table grows forever if nothing prunes it. A scheduled job that deletes rows past expires_at by a comfortable margin keeps the table from becoming a maintenance problem later.
Logging the raw refresh token. It's tempting to log the token value while debugging a failed refresh. Don't. Log the hash, the user id, and the family id instead. The whole point of hashing at rest is undone if it shows up in plaintext in your logs.
Key takeaways
- Access tokens should be short-lived and stateless; refresh tokens are where you get revocation back, since they're checked against a server-side store on every use.
- Rotate refresh tokens on every use rather than reusing the same one, and group them by a family id descended from the original login.
- If a revoked refresh token is presented again, revoke the whole family. That's your primary defense against token theft, not a fallback.
- Store refresh tokens as opaque, hashed values server-side. There's no benefit to making them self-verifying JWTs if you're always hitting the database anyway.
- On the web, prefer an httpOnly, secure, sameSite cookie for the refresh token, and handle the resulting CSRF surface deliberately rather than avoiding cookies out of habit.
- Debounce concurrent refresh calls on the client to avoid false-positive reuse detection under normal race conditions.
Frequently asked questions
How long should a refresh token live?
There's no universal number; it depends on how much friction you're willing to trade for security. Something in the range of a few days to a month is common for a web SaaS product. Shorter forces more frequent re-authentication; longer means a stolen or leaked token has a longer usable window if it somehow evades reuse detection.
Should refresh tokens be stored in the database or Redis?
Either works. Postgres gives you durability and easy auditing (which user, which family, when it was revoked), which matters if you need to investigate incidents later. Redis is faster for high-throughput lookups but usually needs a persistence strategy if you don't want session data lost on a restart. For most SaaS products, Postgres is the simpler starting point; move to Redis only if refresh volume becomes a measurable bottleneck.
What is refresh token rotation and why does it matter?
Rotation means issuing a brand new refresh token every time the old one is used to get a new access token, and invalidating the old one immediately. Without rotation, a single refresh token can be reused indefinitely as long as it hasn't expired, which makes theft far more dangerous since there's no signal when it happens.
Should I store the refresh token in a cookie or localStorage?
Use an httpOnly, secure cookie for web clients. It can't be read by JavaScript, which rules out the most common way tokens get stolen through a compromised frontend dependency or an XSS bug. localStorage is readable by any script on the page and shouldn't hold a long-lived credential.
How do I revoke all sessions for a user, for example after a password change?
Revoke every refresh token row tied to that user id, not just one family. The next time each session's access token expires, its refresh attempt will fail because the corresponding token is marked revoked, forcing a fresh login.
What happens if a refresh token is stolen?
If rotation and reuse detection are in place, the first party (attacker or legitimate user) to use the stolen token succeeds once, and the next attempt by the other party is detected as reuse, which revokes the entire token family and logs out both. It's not perfect protection since the attacker gets one usable window, but it turns an indefinite compromise into a bounded, detectable one.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.