JWT Authentication in Node.js: Refresh Tokens Done Right
How JWT auth actually works in Node.js: signing and verifying, access and refresh token rotation, where to store tokens, and when a session beats a JWT.
JWTs get chosen for the wrong reason. Someone reads that they are stateless, concludes that means no database, and ships an auth system where a stolen token is valid for thirty days and logging out does nothing. The statelessness is real and so is the cost, and the cost is that you cannot take a token back.
This is how to build JWT auth in Node.js so that revocation works, tokens are stored somewhere sensible, and the refresh flow does not become the vulnerability.
What a JWT actually is
Three base64url segments joined by dots: a header, a payload, and a signature. The first two are encoded, not encrypted. Anyone holding the token can read them.
// Decoding needs no secret. Paste any JWT into a base64 decoder
// and the payload is right there in plain text.
const [header, payload, signature] = token.split('.');
console.log(JSON.parse(Buffer.from(payload, 'base64url').toString()));
// { sub: '1234', email: 'user@example.com', iat: 1735689600, exp: 1735693200 }JWT authentication: signing and verifying
npm install jsonwebtoken
npm install -D @types/jsonwebtokenimport jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
type AccessPayload = { sub: string; role: 'user' | 'admin' };
export function signAccessToken(payload: AccessPayload): string {
return jwt.sign(payload, ACCESS_SECRET, {
expiresIn: '15m',
issuer: 'api.example.com',
audience: 'example.com',
});
}
export function verifyAccessToken(token: string): AccessPayload {
// Pin the algorithm. Do not let the token tell you how to verify it.
const decoded = jwt.verify(token, ACCESS_SECRET, {
algorithms: ['HS256'],
issuer: 'api.example.com',
audience: 'example.com',
});
return decoded as AccessPayload;
}That algorithms option is the important line. Older JWT libraries would read the algorithm from the token header and verify accordingly, which let an attacker set it to none and hand you an unsigned token you would happily accept. Modern libraries default to rejecting that, and pinning the list explicitly means you are not relying on the default staying sensible.
Use jwt.verify, never jwt.decode, for anything that grants access. decode reads the payload without checking the signature at all. It is for inspection, and it appears in a surprising number of production middleware functions.
Where to store the token
This is the decision that determines your security posture, and the common answer is the wrong one.
| Location | XSS | CSRF | Verdict |
|---|---|---|---|
| localStorage | Readable by any script on the page | Not affected | Avoid. One bad dependency exfiltrates every token |
| Memory only | Safer, gone on refresh | Not affected | Good for the access token |
| httpOnly cookie | Not readable by JavaScript | Needs SameSite or a CSRF token | Good for the refresh token |
The argument for localStorage is that cookies are vulnerable to CSRF. That was a stronger argument before SameSite existed. XSS defeats every client-readable storage mechanism equally, and a script that can read localStorage can also make requests on the user's behalf, so the comparison is not as balanced as it sounds.
What I use: short-lived access token held in memory in the client, refresh token in an httpOnly, Secure, SameSite=Lax cookie scoped to the refresh endpoint.
Access token vs refresh token
A single long-lived token is the design that causes the trouble. Split it in two and each half has one job.
| Access token | Refresh token | |
|---|---|---|
| Lifetime | 5 to 15 minutes | Days to weeks |
| Sent with | Every API request | Only the refresh endpoint |
| Stored in database | No | Yes, hashed |
| Revocable | No, it just expires quickly | Yes, delete the row |
This is where the statelessness claim gets honest. The access token is genuinely stateless and that is fine because it dies in fifteen minutes. The refresh token is stored server-side, which is what makes logout and revocation possible at all.
Rotation, and detecting theft
Every refresh issues a new refresh token and invalidates the old one. That alone is good practice. What makes it powerful is what you do when an already-used token comes back.
import crypto from 'node:crypto';
// Store a hash, not the token. A leaked database should not be a
// leaked set of live sessions.
const hash = (token: string) =>
crypto.createHash('sha256').update(token).digest('hex');
export async function rotateRefreshToken(presented: string) {
const row = await db.refreshToken.findUnique({
where: { tokenHash: hash(presented) },
});
if (!row) throw new AuthError('Invalid refresh token');
if (row.usedAt) {
// This token was already exchanged. Either it was replayed by an
// attacker, or the legitimate client is retrying. Either way the
// safe response is to kill the whole family.
await db.refreshToken.deleteMany({ where: { familyId: row.familyId } });
throw new AuthError('Token reuse detected. All sessions revoked.');
}
if (row.expiresAt < new Date()) throw new AuthError('Refresh token expired');
await db.refreshToken.update({
where: { id: row.id },
data: { usedAt: new Date() },
});
const next = crypto.randomBytes(32).toString('base64url');
await db.refreshToken.create({
data: {
tokenHash: hash(next),
userId: row.userId,
familyId: row.familyId, // same family, so reuse detection still works
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
return { accessToken: signAccessToken({ sub: row.userId, role: 'user' }), refreshToken: next };
}Reuse detection is the whole point of the familyId. If an attacker steals a refresh token and uses it, the real user's next refresh presents a token that has already been consumed, the family is wiped, and both parties are logged out. The user re-authenticates and the attacker is locked out. Without rotation you would never have known.
Note the refresh token here is random bytes, not a JWT. It does not need to carry claims, because you look it up anyway. A random string is shorter, reveals nothing, and cannot be misused by code that forgets to verify.
HS256 or RS256
HS256 uses one shared secret to both sign and verify. RS256 uses a private key to sign and a public key to verify. The practical difference is who needs to be trusted.
With HS256, anything that can verify a token can also mint one, because it is the same secret. That is fine when a single service issues and checks its own tokens. The moment a second service needs to verify, you have handed it the ability to impersonate any user, and the blast radius of a leak from that service now includes your auth system.
RS256 fixes that: distribute the public key freely, keep the private key in the issuer. Use HS256 for a single application, RS256 as soon as verification happens anywhere other than where signing happens.
Logging out
Here is the honest answer that JWT tutorials tend to skip: you cannot revoke an access token. It is valid until it expires, because validity is a property of the signature and not of any record you control. Deleting it from the client stops that client using it and does nothing about a copy.
So logout means two things. Delete the refresh token row, which stops new access tokens being issued. And accept a window, at most the access token lifetime, during which a stolen token still works. That window is exactly why access tokens are fifteen minutes rather than thirty days.
export async function logout(req: Request, res: Response) {
const presented = req.cookies.refreshToken;
if (presented) {
// Kill the whole family, not just this token, so a stolen sibling
// cannot keep the session alive.
const row = await db.refreshToken.findUnique({
where: { tokenHash: hash(presented) },
});
if (row) {
await db.refreshToken.deleteMany({ where: { familyId: row.familyId } });
}
}
res.clearCookie('refreshToken', { path: '/auth/refresh' });
res.status(204).end();
}If you genuinely need instant revocation, for an account compromise or a fired employee, add a check the access token cannot bypass: store a tokenVersion on the user, include it in the payload, and compare on each request. That is a database read per request, which discards the main advantage of JWTs. Worth it for admin routes, rarely worth it everywhere.
Middleware
import type { Request, Response, NextFunction } from 'express';
declare global {
namespace Express {
interface Request {
user?: { id: string; role: 'user' | 'admin' };
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing bearer token' });
}
try {
const payload = verifyAccessToken(header.slice(7));
req.user = { id: payload.sub, role: payload.role };
next();
} catch (error) {
// Distinguish expired from invalid: the client should refresh on the
// first and re-authenticate on the second.
if (error instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}That distinction matters for the client. An expired token means "call the refresh endpoint and retry". An invalid one means "send the user to the login page". Collapsing both into a bare 401 gives you a client that logs people out every fifteen minutes.
Refreshing from the client without a stampede
When an access token expires, several in-flight requests fail at once. A naive interceptor fires a refresh for each, and with rotation enabled those refreshes race: the first succeeds, the rest present a now-consumed token, reuse detection fires, and the user is logged out by their own client.
let refreshing: Promise<string> | null = null;
async function getFreshAccessToken(): Promise<string> {
// Every concurrent caller awaits the same in-flight refresh.
if (!refreshing) {
refreshing = fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // sends the httpOnly cookie
})
.then(async (res) => {
if (!res.ok) throw new Error('Refresh failed');
const { accessToken } = await res.json();
return accessToken as string;
})
.finally(() => {
refreshing = null;
});
}
return refreshing;
}One shared promise, one refresh. This is the same de-duplication problem query libraries solve for ordinary requests, which I covered in fetching data in React, and it bites harder here because the failure mode is a logout rather than a duplicate GET.
Four mistakes I keep seeing
Using decode instead of verify. It reads the payload with no signature check, so any attacker can forge a token by editing the base64 and skipping the signature entirely. It appears in middleware because it is simpler and it works in testing.
The same secret for access and refresh tokens. If they share a secret, a refresh token is a valid access token and vice versa, so the short access lifetime that the whole design rests on is bypassable. Two secrets, two environment variables.
A weak or committed secret. An HS256 secret should be at least 32 random bytes. A memorable string is brute-forceable offline against any token you have issued, and there are tools that do exactly that. Generate it with crypto.randomBytes(32).toString('base64') and keep it out of the repository.
No expiry at all. Omitting expiresIn produces a token valid forever. Nothing warns you, and it will pass every test you write.
JWT vs session: when not to use JWTs
A plain session with an opaque ID in an httpOnly cookie is simpler, revocable immediately, and correct for most applications. If your API and frontend share a domain and you already have a database, that is probably what you want.
JWTs earn their complexity when several independent services need to verify a token without calling an auth service, or when you are issuing credentials to third parties. For a single Next.js app with one database, reaching for JWTs is usually cargo cult, and the refresh rotation machinery above is work you did not need to do.
The version I would avoid entirely is the one this post opened with: a single long-lived JWT in localStorage with no refresh flow and no server-side record. It is the most common implementation and it has no logout.
The checklist
One thing that is not in this post but belongs next to it: the login and refresh endpoints need tight limits keyed on both IP and account, or the whole design above is bypassed by brute force. That is covered in API rate limiting.
Before shipping: algorithm pinned on verify, secrets from environment variables and different for access and refresh, access tokens under fifteen minutes, refresh tokens hashed in the database with rotation and reuse detection, httpOnly plus Secure plus SameSite on the cookie, 401 and 403 used correctly, and a single-flight refresh on the client.
Everything on that list exists because leaving it off produces a system that works perfectly in testing and fails in a specific, well-understood way in production.
Frequently asked questions
Where should I store a JWT?
Keep the short-lived access token in memory in the client, and the refresh token in an httpOnly, Secure, SameSite cookie scoped to the refresh endpoint. Avoid localStorage: any script on the page can read it, so one compromised dependency exfiltrates every token. The CSRF argument against cookies is much weaker now that SameSite exists.
How do I revoke a JWT?
You cannot revoke an access token; it stays valid until it expires, because validity is a property of the signature rather than any record you control. That is why access tokens should live 5 to 15 minutes. Logout means deleting the stored refresh token so no new access tokens are issued, and accepting a short window where an already-issued token still works.
What is refresh token rotation?
Each refresh issues a new refresh token and marks the old one used. If a used token is ever presented again, either it was replayed by an attacker or the client is retrying, so the safe response is to revoke the whole token family. That turns a stolen refresh token into a detectable event instead of a silent long-lived compromise.
Should I use JWTs or sessions?
For a single application with one database, a plain session with an opaque ID in an httpOnly cookie is simpler and revocable immediately. JWTs earn their complexity when several independent services must verify a token without calling an auth service, or when issuing credentials to third parties.


