JWT Refresh Token Flow: Storage, Rotation and Logout
How access and refresh tokens work together, where to store each one in the browser, and how to rotate, revoke and log out properly. Built step by step from the RFCs.

12 min read · Last updated: October 25, 2026
A JWT refresh token flow pairs two credentials. A short-lived access token (a JWT) proves who you are on every API call. A long-lived refresh token, sent only to your auth endpoint, gets you a new access token when the old one expires. Keep the access token in memory, the refresh token in an HttpOnly cookie, and rotate it on every use.
Came here from Instagram, LinkedIn, X or YouTube? Here is the full source list you came for. Everything below is built from these, nothing else.
The specs
- RFC 6749, The OAuth 2.0 Authorization Framework (October 2012), sections 1.5, 6 and 10.4
- RFC 7519, JSON Web Token (May 2015), the exp, iat and jti claims
- RFC 7009, OAuth 2.0 Token Revocation (August 2013)
The current best practice
- RFC 9700, Best Current Practice for OAuth 2.0 Security (January 2025), section 4.14
- RFC 10017, OAuth 2.0 for Browser-Based Applications (August 2026)
The docs
Most JWT tutorials stop at "sign a token, send it back, done". That works until a user clicks logout and their token keeps working, or until you put a 30-day JWT in localStorage and one XSS bug hands it to an attacker.
In this tutorial you will build the part those tutorials skip. A login that issues two tokens, a refresh endpoint that rotates and catches stolen tokens, a client that renews silently, and a logout that actually ends the session.
Key takeaways
- Access tokens should be short-lived (I use 15 minutes). The OWASP JWT Cheat Sheet says token reuse "can be mitigated by using short expiration time".
- Refresh tokens live in an HttpOnly, Secure, SameSite=Strict cookie. OWASP says plainly: do not store session identifiers in localStorage.
- Rotate the refresh token on every use. RFC 9700 (January 2025) says refresh tokens for public clients MUST be sender-constrained or rotated.
- Reuse of an old refresh token means theft. The server should revoke the whole token family.
- Logout means revoking the refresh token server-side, clearing the cookie and dropping the access token from memory.
- For sensitive apps, RFC 10017 (August 2026) strongly recommends a Backend for Frontend so tokens never reach the browser at all.
What you'll build and why
You will build a token-based auth flow in Node.js with Express. It issues a 15-minute JWT access token plus an opaque refresh token stored hashed in your database. The refresh token rotates on every use, reuse triggers a full revocation, and logout revokes it server-side so the session ends immediately.
Here is the whole JWT refresh token flow, in six steps:
- User logs in. Server returns an access token in the JSON body and sets a refresh token cookie.
- Client keeps the access token in a JavaScript variable and sends it as
Authorization: Bearer .... - Access token expires. The API returns 401.
- Client calls
/auth/refresh. The browser sends the cookie automatically. - Server checks the refresh token, burns it, issues a new pair.
- On logout, the server revokes the refresh token and clears the cookie.
The "why" comes straight from the spec. RFC 6749 section 1.5 defines refresh tokens as "credentials used to obtain access tokens" and says they are "intended for use only with authorization servers". That split is the whole point. The access token travels everywhere, so it should die fast. The refresh token travels to one endpoint, so it can live longer.
What is a refresh token?
A refresh token is a long-lived credential your client trades for a new access token when the current one expires. It is sent only to the auth server, never to your regular API. RFC 6749 requires it to be kept confidential in transit and storage, bound to the client it was issued to, and impossible to guess.
It does not need to be a JWT. Nothing in RFC 6749 says it must be. I use a random 256-bit string, because the server has to look it up in a database anyway to check whether it was revoked. A self-contained JWT buys you nothing there.
| Access token | Refresh token | |
|---|---|---|
| Format | JWT (signed, self-contained) | Opaque random string |
| Sent to | Every API request | Only /auth/refresh and /auth/logout |
| Lifetime (my defaults) | 15 minutes | 7 days idle, 30 days absolute |
| Stored on client | In memory | HttpOnly cookie |
| Stored on server | Nowhere (stateless) | Hashed, in a database table |
| Can you revoke it instantly? | Not without a denylist | Yes, revoke the row |
Why do access tokens expire?
Access tokens expire because a JWT cannot be "un-issued". Once signed, it is valid until its exp time to anyone who holds it. RFC 7519 defines exp as the time "on or after which the JWT MUST NOT be accepted". A short lifetime caps the damage of a leaked token to minutes instead of weeks.
That is the trade-off. Stateless verification is fast because the API never calls a database. The price is that the API also never learns the user logged out. Short expiry plus a refresh step is how you get both.
Prerequisites
- Node.js 22 or newer and Express
jsonwebtokenandcookie-parserfrom npm- A database. The examples use a generic
dbhelper with SQL, so swap in Postgres, MySQL or SQLite - HTTPS in production, since the
Securecookie flag requires it (MDN notes localhost is exempt) - A basic understanding of what a JWT is. If you need a refresher, read our JWT explainer first
npm install express jsonwebtoken cookie-parser
Step 1: Create the refresh token table
Store a hash of the token, never the raw value. If your database leaks, hashes are useless to an attacker. The family_id ties every rotated token back to the original login, which is what makes theft detection possible later.
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
family_id UUID NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ NOT NULL,
family_expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON refresh_tokens (family_id);
CREATE INDEX ON refresh_tokens (user_id);
family_expires_at is the absolute cap. RFC 10017 says that when you rotate a refresh token with a fixed expiry, the new one must not outlive the original. Every token in a family shares the same hard end date.
Step 2: Issue both tokens on login
On login, sign a short JWT and generate a random refresh token. Return the access token in the response body. Put the refresh token in a cookie the JavaScript on your page can never read.
import crypto from "node:crypto";
import jwt from "jsonwebtoken";
const ACCESS_TTL = "15m";
const IDLE_MS = 7 * 24 * 60 * 60 * 1000;
const ABSOLUTE_MS = 30 * 24 * 60 * 60 * 1000;
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
function signAccessToken(userId) {
return jwt.sign({ sub: userId, jti: crypto.randomUUID() }, process.env.JWT_SECRET, {
algorithm: "HS256",
expiresIn: ACCESS_TTL,
});
}
async function issueRefreshToken(userId, familyId, familyExpiresAt) {
const token = crypto.randomBytes(32).toString("base64url");
await db.query(
`INSERT INTO refresh_tokens (id, user_id, family_id, token_hash, last_used_at, family_expires_at)
VALUES ($1, $2, $3, $4, now(), $5)`,
[crypto.randomUUID(), userId, familyId, sha256(token), familyExpiresAt]
);
return token;
}
function setRefreshCookie(res, token) {
res.cookie("refresh_token", token, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/auth",
maxAge: IDLE_MS,
});
}
app.post("/auth/login", async (req, res) => {
const user = await verifyPassword(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: "invalid_credentials" });
const familyExpiresAt = new Date(Date.now() + ABSOLUTE_MS);
const refresh = await issueRefreshToken(user.id, crypto.randomUUID(), familyExpiresAt);
setRefreshCookie(res, refresh);
res.json({ accessToken: signAccessToken(user.id) });
});
Each cookie flag does one job. According to MDN, HttpOnly forbids JavaScript from reading the cookie through document.cookie, which "mitigates attacks against cross-site scripting". Secure sends it only over HTTPS. SameSite=Strict sends it only on requests from your own site. Path=/auth keeps it off every other request.
Where to store a JWT in the browser: localStorage vs cookie
Store the access token in memory and the refresh token in an HttpOnly cookie. localStorage is readable by any script on your origin, so one XSS bug exposes everything in it. The OWASP HTML5 Security Cheat Sheet says: "Do not store session identifiers in local storage as the data is always accessible by JavaScript."
| Storage | Readable by JS (XSS risk) | Sent automatically (CSRF risk) | Survives reload | Use it for |
|---|---|---|---|---|
| localStorage | Yes | No | Yes | Neither token |
| sessionStorage | Yes | No | Per tab only | Neither token |
| JS memory variable | Yes, while page runs | No | No | Access token |
| HttpOnly cookie | No | Yes | Yes | Refresh token |
Be honest with yourself about what this buys you. RFC 10017 points out that if an attacker can run code in your page, they can simply request tokens the same way your app does, and no storage choice prevents that. An HttpOnly cookie stops the refresh token from being copied out and used later from another machine. It does not make XSS harmless.
The cookie side brings CSRF risk, because browsers attach cookies automatically. SameSite=Strict handles most of it. RFC 10017 also lists requiring a custom request header as a CSRF defence, which we add in step 4.
Step 3: Verify the access token on every API request
This middleware is the stateless part. No database call, just a signature and expiry check. If it fails, return 401 so the client knows to refresh.
function requireAuth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: "missing_token" });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"] });
next();
} catch {
res.status(401).json({ error: "invalid_or_expired_token" });
}
}
app.get("/api/me", requireAuth, (req, res) => res.json({ userId: req.user.sub }));
Pin the algorithms list. Never let the token's own header decide how it gets verified.
Step 4: Rotate the refresh token and detect reuse
The refresh endpoint burns the old token and issues a new one every time. If someone presents a token that was already used, two parties hold it. RFC 9700 section 4.14.2 says the server then "will revoke the active refresh token", so you kill the entire family and the user logs in again.
app.post("/auth/refresh", async (req, res) => {
if (req.get("X-Requested-With") !== "fetch") return res.status(403).end();
const raw = req.cookies.refresh_token;
if (!raw) return res.status(401).json({ error: "no_refresh_token" });
const row = await db.one("SELECT * FROM refresh_tokens WHERE token_hash = $1", [sha256(raw)]);
if (!row) return res.status(401).json({ error: "unknown_token" });
if (row.used_at || row.revoked_at) {
await db.query("UPDATE refresh_tokens SET revoked_at = now() WHERE family_id = $1", [row.family_id]);
res.clearCookie("refresh_token", { path: "/auth" });
return res.status(401).json({ error: "token_reuse_detected" });
}
const now = Date.now();
const idleExpired = now - new Date(row.last_used_at).getTime() > IDLE_MS;
const absoluteExpired = now > new Date(row.family_expires_at).getTime();
if (idleExpired || absoluteExpired) {
return res.status(401).json({ error: "session_expired" });
}
const claimed = await db.query(
"UPDATE refresh_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL",
[row.id]
);
if (claimed.rowCount === 0) return res.status(401).json({ error: "token_reuse_detected" });
const next = await issueRefreshToken(row.user_id, row.family_id, row.family_expires_at);
setRefreshCookie(res, next);
res.json({ accessToken: signAccessToken(row.user_id) });
});
Three rules from the specs are in that code:
- Rotation. RFC 6749 section 6 says when a new refresh token is issued, "the client MUST discard the old refresh token". The new cookie overwrites the old one.
- Idle expiry. RFC 9700 says refresh tokens "SHOULD expire if the client has been inactive for some time". That is the
IDLE_MScheck. - Hard cap. The new token inherits
family_expires_at, so rotation never extends past the original limit, as RFC 10017 requires.
The conditional UPDATE ... WHERE used_at IS NULL matters. Without it, two requests arriving at the same moment could both pass the check and both get new tokens.
How to automatically extend JWT expiration
You don't extend a JWT. Its exp is signed into it and cannot change. Instead, the client silently swaps it for a new one: when an API call returns 401, it calls the refresh endpoint, gets a fresh access token, and retries the original request. The user never sees it happen.
The session itself extends through rotation. Each refresh resets the idle clock, so an active user stays logged in until the absolute cap, and an idle one gets logged out after 7 days.
Step 5: Refresh silently on the client
This wrapper keeps the access token in a module variable, retries once after a refresh, and makes sure five parallel 401s trigger one refresh, not five. Five parallel refreshes with rotation would look exactly like token reuse and log the user out.
let accessToken = null;
let refreshing = null;
async function refreshAccessToken() {
refreshing ??= fetch("/auth/refresh", {
method: "POST",
credentials: "same-origin",
headers: { "X-Requested-With": "fetch" },
})
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("refresh_failed"))))
.then((data) => (accessToken = data.accessToken))
.finally(() => (refreshing = null));
return refreshing;
}
export async function api(url, options = {}) {
const send = () =>
fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
});
if (!accessToken) await refreshAccessToken().catch(() => {});
const res = await send();
if (res.status !== 401) return res;
try {
await refreshAccessToken();
} catch {
window.location.href = "/login";
return res;
}
return send();
}
On a page reload, accessToken is null, so the first call refreshes using the cookie. That is how the user stays logged in across reloads without anything in localStorage. If several browser tabs are open, each tab refreshing on its own can still collide, so in production share the refresh through a BroadcastChannel or accept that a rare collision sends a user back to login.
How to log out with JWT
Logging out with JWT means ending the refresh token on the server, not just deleting tokens in the browser. Revoke the refresh token family in your database, clear the cookie, and drop the access token from memory. The access token stays technically valid until it expires, which is why it should be short.
app.post("/auth/logout", async (req, res) => {
const raw = req.cookies.refresh_token;
if (raw) {
const row = await db.one("SELECT family_id FROM refresh_tokens WHERE token_hash = $1", [sha256(raw)]);
if (row) {
await db.query("UPDATE refresh_tokens SET revoked_at = now() WHERE family_id = $1", [row.family_id]);
}
}
res.clearCookie("refresh_token", { path: "/auth" });
res.status(204).end();
});
export async function logout() {
await fetch("/auth/logout", { method: "POST", headers: { "X-Requested-With": "fetch" } });
accessToken = null;
window.location.href = "/login";
}
Notice logout succeeds even when the token is missing or unknown. RFC 7009, the token revocation spec, does the same: the server responds with HTTP 200 even for an invalid token, because the goal of making it unusable is already met.
Want "log out of all devices"? Revoke by user_id instead of family_id. Do the same on a password change. RFC 9700 says the server "MAY revoke refresh tokens automatically" on events like a password change or logout.
How to invalidate a JWT before it expires
You can't invalidate a signed JWT itself, only refuse it. The standard way is a denylist: give each token a unique jti claim, store revoked jti values until their exp passes, and check that list on every request. The OWASP JWT Cheat Sheet names a deny list of revoked tokens as the way to handle session invalidation.
Step 6 (optional): Add a denylist for instant access token revocation
Most apps don't need this. A 15-minute window is acceptable for a normal logout. You need it when "this account is compromised, kill it now" has to mean now.
async function revokeAccessToken(payload) {
const ttlSeconds = payload.exp - Math.floor(Date.now() / 1000);
if (ttlSeconds > 0) await redis.set(`deny:${payload.jti}`, "1", { EX: ttlSeconds });
}
function requireAuthStrict(req, res, next) {
requireAuth(req, res, async () => {
if (await redis.exists(`deny:${req.user.jti}`)) {
return res.status(401).json({ error: "token_revoked" });
}
next();
});
}
RFC 7519 says jti "provides a unique identifier for the JWT" and can guard against replay, which is why step 2 already put one in every token. Each entry expires on its own when the token would have expired anyway, so the list never grows without bound.
The catch, which OWASP also points out: a denylist makes your "stateless" auth less stateless. Every request now hits Redis. If you need this on every route, ask whether plain server sessions would be simpler.
Step 7: Decide if tokens should be in the browser at all
For business or sensitive applications, RFC 10017 recommends the Backend for Frontend (BFF) pattern. A small server you control holds the tokens as a confidential client, and the browser only gets an HttpOnly session cookie. The spec says this architecture "is strongly recommended for business applications, sensitive applications".
| Architecture | Where tokens live | RFC 10017 position |
|---|---|---|
| Backend for Frontend (BFF) | Server only | Strongly recommended for sensitive apps |
| Token-mediating backend | Server obtains tokens, browser uses the access token | Only if a proxying BFF can't meet your requirements |
| Browser-only client | Both in the browser | Most exposed; malicious code can get both tokens |
The flow in this tutorial keeps the refresh token out of JavaScript's reach while the access token lives in the page. It is a solid default for a typical app. If you handle money or health data, build the BFF. This exact trade-off also comes up in interviews, and our guide on why developers fail system design interviews covers how to talk through it.
Common mistakes
- A 30-day access token "so users stay logged in". That is a refresh token's job. A long access token is a long breach window.
- Storing either token in localStorage. OWASP warns that a single XSS can steal all the data in it.
- No rotation. A refresh token that never changes can be replayed for its whole lifetime, and you will never know.
- Rotation without reuse detection. Burning old tokens is half the job. Seeing an old one come back is your theft alarm.
- Logout that only clears the browser. If the refresh token still works on the server, anyone holding a copy still has the session.
- Parallel refresh calls. Without the shared promise in step 5, a dashboard firing six requests at once will trip reuse detection and log your own user out.
- Storing raw refresh tokens. Hash them. RFC 6749 says refresh tokens "MUST be kept confidential in transit and storage".
- Shipping AI-generated auth code as-is. Assistants often reach for
localStorage.setItem("token", ...). Read our guide on reviewing AI-generated code before you merge it.
Next steps
- Add the
user_idrevocation from the logout section to your password-change handler. - Write a scheduled job that deletes refresh token rows past
family_expires_at. - Log every
token_reuse_detectedevent. Each one is either a bug in your client or a stolen token, and you want to know which. - If your app is sensitive, move token handling into a BFF as described in step 7.
FAQ
What is the difference between an access token and a refresh token?
An access token is short-lived and sent with every API request to prove identity. A refresh token is long-lived and sent only to the auth server to get new access tokens. RFC 6749 says refresh tokens are "intended for use only with authorization servers", never with regular resource APIs.
Should a refresh token be a JWT?
It can be, but it doesn't need to be, and I recommend against it. The server has to check a database to know if a refresh token was rotated or revoked, so a self-contained signed token adds size without saving a lookup. A random 32-byte string stored as a hash is simpler.
Is it safe to store a JWT in a cookie?
Yes, if the cookie is HttpOnly, Secure and SameSite=Strict, and you add CSRF protection. HttpOnly blocks JavaScript from reading it, Secure keeps it on HTTPS, and SameSite stops cross-site requests from carrying it. That is safer than localStorage, which any script on your page can read.
What happens to the access token after logout?
It keeps working until its exp time, because a signed JWT can't be recalled. That is why access tokens should be short. If you need instant cut-off, add a jti denylist checked on every request, or move to a Backend for Frontend so the token never leaves your server.
Build the refresh endpoint from step 4 first, since it is the piece most apps get wrong, and inspect what you issue with our JWT decoder or the rest of the free tools on Art of Code. For the token format itself, start with our JWT explainer.
Written by Adithya Guttha, Founder of Art of Code.
Sign in to like and save posts.
Keep reading



Discussion
Comments (0)
Want to join the conversation?
Sign in to commentNo comments yet. Be the first to comment!