JWT refresh-token rotation and RBAC, the boring-but-correct way
Most auth tutorials stop at “issue a JWT, check it in middleware.” That gets you a login form and a false sense of security. The interesting questions start after that: what happens when a token leaks? How does a user stay logged in for weeks without you keeping a long-lived, forgeable credential in the wild? What stops a stolen refresh token from being a permanent backdoor?
When I built MERN Notes — a full-stack notes app — I made the auth the point of the project rather than an afterthought. I studied how Better Auth and FusionAuth handle sessions and implemented the same patterns: short-lived access tokens, refresh-token rotation with reuse detection, and role-based access control, backed by PostgreSQL (via Drizzle ORM) with MongoDB for document storage. Here’s the reasoning, because the reasoning is the part that transfers.
Two tokens, two lifetimes
The core split: access tokens are short-lived, refresh tokens are long-lived and revocable.
- The access token is a stateless JWT with a lifetime measured in minutes. It’s sent on every request, verified with a signature check, and never touches the database on the hot path. Because it expires fast, a leaked access token is only dangerous for a few minutes.
- The refresh token lives for days or weeks and does exactly one thing: exchange itself for a new access token. It’s stored server-side (hashed), so it can be revoked — something you fundamentally cannot do to a stateless JWT.
The whole design exists to resolve one tension: you want long sessions (users hate re-logging-in) but short-lived credentials (leaks should expire quickly). Two tokens with two lifetimes give you both.
Why rotation, and what reuse detection catches
Here’s the scenario a plain refresh token can’t handle. An attacker steals a refresh token. It’s valid for two weeks. For two weeks they can mint access tokens whenever they like, and you have no way to know. Expiry doesn’t save you; the token is still “valid.”
Rotation means every time a refresh token is used, it’s invalidated and a brand-new one is issued. Each refresh token is single-use. On its own that’s nice hygiene. The powerful part is what it enables: reuse detection.
Because each token is single-use, seeing the same refresh token twice is an anomaly that can only mean one thing — someone kept a copy. Concretely:
- User’s browser has refresh token
A. It refreshes; the server issuesBand marksAused, chainingBto the same session family. - An attacker who copied
Aearlier now tries to use it. - The server sees
A— already used. That’s the alarm. - The correct response is not “reject this one request.” It’s revoke the entire token family. Both the attacker and the legitimate user are logged out, and the theft is contained.
A rough sketch of the exchange:
async function refresh(presentedToken: string) {
const record = await findByHash(hash(presentedToken))
if (!record) throw new AuthError("invalid")
// The token exists but was already rotated away → reuse. Burn the family.
if (record.usedAt) {
await revokeFamily(record.familyId)
throw new AuthError("reuse detected — session revoked")
}
await markUsed(record.id)
const next = await issueRefreshToken({ familyId: record.familyId })
const access = signAccessToken({ sub: record.userId })
return { access, refresh: next }
}
Two details that matter: store the hash of the refresh token, never the raw
value — if your DB leaks, the tokens in it are useless. And track a
familyId so one compromised token takes down the whole chain, not just
itself.
RBAC sits on top, and stays out of the way
Authentication answers “who are you.” Authorization answers “what are you allowed to do.” Keeping them separate is what keeps both simple.
The access token carries the user’s role in its claims. Since the token is already verified on every request, the role is trustworthy without another DB lookup:
function requireRole(...roles: Role[]) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: "forbidden" })
}
next()
}
}
router.delete("/notes/:id", requireRole("admin", "owner"), deleteNote)
The thing I got right here — after first getting it wrong — is not baking permissions into the token. The token carries a role, a stable identifier. The mapping from role to concrete permissions lives in the app, where I can change it without waiting for every token in circulation to expire. Put raw permissions in the JWT and every policy change is gated on token lifetime.
What this is really about
Rotation, hashing, family revocation, role-not-permission — none of it is clever. It’s a series of boring, defensive choices, each closing a specific hole:
- Short access tokens → leaks expire fast.
- Server-side refresh tokens → sessions are revocable.
- Rotation + reuse detection → a stolen token gets caught and contained.
- Hashed storage → a DB leak doesn’t hand over live tokens.
- Role in the token, permissions in the app → policy changes don’t wait on expiry.
Auth is one of those areas where “boring and correct” beats “clever” every time, because the failure mode isn’t a bug ticket — it’s someone in an account that isn’t theirs. That’s exactly why I wanted to build it end to end instead of reaching for a library and hoping.