Most JWT tutorials stop at jwt.sign and call it a day. In fintech that is not enough: a stolen long-lived token means an attacker can move money for days before anyone notices.
In this guide I show you the production pattern: 15-minute access tokens, rotating refresh tokens in httpOnly cookies, and reuse detection that revokes the whole session family when a token is used twice.
1. The Problem: Long-Lived Tokens Are a Ticking Bomb
A JWT is self-contained and stateless, which is exactly why it is dangerous when it lives too long. Whoever holds a valid access token IS the user until it expires — there is no server-side session to kill. OWASP is blunt about this: keep lifetimes short, validate exp, iss and aud strictly, and never accept alg none.
The Most Common Mistake
Issuing a 24-hour or 7-day access token and storing it in localStorage. One XSS injection reads localStorage, exfiltrates the token, and the attacker has full API access for days with no way for you to revoke it.
The fix has three parts that work together: access tokens that expire in minutes, refresh tokens that live longer but rotate on every use, and storage that keeps long-lived credentials out of JavaScript reach. Let’s build exactly that.
2. The Minimum Concepts: Access, Refresh, Rotation
Before touching code, you need four ideas clear. They are simple, but getting any of them wrong voids the whole design.
Access token
Short-lived · 5–15 min
Sent with every API request in the Authorization header. Its short TTL is the damage window: if stolen, it dies in minutes.
Refresh token
Long-lived · 7–30 days
Used only against POST /auth/refresh to mint new access tokens. Never sent to business endpoints, never readable from JavaScript.
Rotation
One use per token
Every refresh invalidates the old refresh token and issues a brand-new pair. A refresh token can only ever be used once.
Reuse detection
Theft alarm
If an already-rotated token shows up again, someone copied it. The server revokes the entire token family and forces re-login.
Storage split
Cookie + memory
Refresh token in an httpOnly, Secure, SameSite cookie scoped to /auth/refresh. Access token in memory (or short-lived cookie). Never localStorage.
Claims that matter
exp · iss · aud · jti
Validate expiration, issuer and audience on every verify, pin the algorithm, and use jti to track each refresh token in your store.
My Recommended Lifetimes for Fintech
Access token: 15 minutes. Refresh token: 7 days with rotation and a hard 30-day absolute cap. Banking or healthcare flows: 5-minute access tokens and step-up re-authentication for sensitive operations.
3. Step-by-Step Tutorial: Rotation in Node.js
We’ll use jsonwebtoken (v9, the Auth0-maintained library with jwt.sign and jwt.verify) on Express. The same pattern ports to jose — I show the equivalent at the end.
Step 1
Project setup
Install Express plus jsonwebtoken and cookie-parser, and keep two separate secrets: one for access tokens, one for refresh tokens. Separate keys mean a leaked access secret cannot forge refresh tokens.
npm install express jsonwebtoken cookie-parser import express from "express"; import jwt from "jsonwebtoken"; import cookieParser from "cookie-parser"; import crypto from "crypto"; const app = express(); app.use(express.json()); app.use(cookieParser());
Step 2
Login issues the token pair
After verifying credentials, sign a 15-minute access token and a 7-day refresh token with a unique jti. The refresh token goes into a locked-down cookie; only the access token goes back in the JSON body.
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: "15m", audience: "fintech-api", issuer: "my-fintech" }
);
const tokenId = crypto.randomUUID();
sessions.set(tokenId, { userId: user.id });
const refreshToken = jwt.sign(
{ sub: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: "7d", audience: "fintech-api", issuer: "my-fintech", jwtid: tokenId }
);
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/auth/refresh",
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken });Step 3
Protect routes with strict verify
Every protected route verifies signature, algorithm, audience and issuer. Pin algorithms to HS256 (or your RS256 key) so an attacker can never downgrade the token to alg none.
function requireAuth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.replace("Bearer ", "");
try {
req.user = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, {
algorithms: ["HS256"],
audience: "fintech-api",
issuer: "my-fintech"
});
return next();
} catch (err) {
return res.status(401).json({ message: "Invalid or expired token" });
}
}
app.get("/api/balance", requireAuth, (req, res) => {
res.json({ balance: getBalance(req.user.sub) });
});Step 4
Rotate on every refresh, detect reuse
This is the heart of the pattern. Look the jti up in your store (Redis in production, a Map in this example). Unknown jti means the token was already rotated — treat it as theft, revoke the whole family, and force re-login.
app.post("/auth/refresh", (req, res) => {
const oldToken = req.cookies.refreshToken;
if (!oldToken) return res.status(401).json({ message: "Missing token" });
let payload;
try {
payload = jwt.verify(oldToken, process.env.REFRESH_TOKEN_SECRET, {
algorithms: ["HS256"],
audience: "fintech-api",
issuer: "my-fintech"
});
} catch (err) {
return res.status(401).json({ message: "Invalid token" });
}
if (!sessions.has(payload.jti)) {
revokeFamily(payload.sub); // reuse detected: burn everything
return res.status(401).json({ message: "Reuse detected" });
}
sessions.delete(payload.jti);
const tokenId = crypto.randomUUID();
sessions.set(tokenId, { userId: payload.sub });
const accessToken = jwt.sign({ sub: payload.sub },
process.env.ACCESS_TOKEN_SECRET, { expiresIn: "15m" });
const refreshToken = jwt.sign({ sub: payload.sub },
process.env.REFRESH_TOKEN_SECRET, { expiresIn: "7d", jwtid: tokenId });
res.cookie("refreshToken", refreshToken, {
httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh"
});
res.json({ accessToken });
});Step 5
Logout revokes server-side
Clearing the cookie is not enough — a copied token would still verify. Delete the jti from the store so the refresh token dies immediately; the access token then expires on its own within minutes.
app.post("/auth/logout", (req, res) => {
const token = req.cookies.refreshToken;
if (token) {
const decoded = jwt.decode(token);
if (decoded && decoded.jti) sessions.delete(decoded.jti);
}
res.clearCookie("refreshToken", { path: "/auth/refresh" });
res.json({ message: "Logged out" });
});Tip: Prefer jose for new projects
The jose library (by panva) is dependency-free, ESM-native, and works across Node, edge runtimes and browsers. Its API is new jose.SignJWT({...}).setProtectedHeader({ alg: 'HS256' }).setExpirationTime('15m').sign(secret) for signing and jose.jwtVerify(token, secret, { audience, issuer }) for verification.
4. jsonwebtoken vs jose: Which Library in 2026?
Both libraries issue valid tokens, but they come from different eras. Here is how I choose between them for production work.
📦 jsonwebtoken (Auth0)
Battle-tested default
18k+ stars, synchronous jwt.sign / jwt.verify API, HS256/RS256/ES256 support. Ideal if your codebase is CommonJS or you already depend on it.
Manual claim checks
You pass expiresIn, audience and issuer as options and algorithms on verify. No rotation helpers — you build the store and reuse detection yourself, like in this guide.
Watch the footguns
Always pass algorithms explicitly on verify and never trust jwt.decode for auth decisions — decode skips signature verification entirely.
✨ jose (panva)
Modern ESM standard
Zero dependencies, WebCrypto under the hood, same code runs on Node 20+, Cloudflare Workers, Deno and browsers. My pick for greenfield projects.
Builder-style API
SignJWT with chained setIssuer, setAudience and setExpirationTime; jwtVerify validates claims in one call. Fewer silent misconfigurations.
Same rotation pattern
jose does not rotate for you either — the refresh store, jti tracking and reuse detection from section 3 apply unchanged.
5. Where Each Token Lives: Cookies Done Right
Storage is where most implementations fail. The rule is simple: long-lived credentials must never be readable by JavaScript, and cookies must be scoped as narrowly as possible.
httpOnly + Secure + SameSite
The refresh cookie needs all three flags: httpOnly blocks XSS reads, Secure restricts it to HTTPS, SameSite=strict blocks CSRF. This trio is non-negotiable per current OWASP guidance.
Path-scoped cookies
Set path to /auth/refresh so the browser only sends the refresh token to the refresh endpoint — never to every API call. Smaller exposure surface by design.
Access token in memory
Keep the 15-minute access token in a module-scoped variable and re-fetch it via silent refresh on reload. Memory dies with the tab, which is exactly what you want.
Never localStorage
localStorage and sessionStorage are readable by any injected script. One stored-XSS flaw and every token you saved there belongs to the attacker.
Native apps differ
On mobile there are no httpOnly cookies: use the OS secure storage (Keychain, Keystore) for refresh tokens and short-lived in-memory access tokens.
6. Rotation in Production: The Three Rhythms
Rotation is not a single endpoint — it is a lifecycle. These are the three rhythms I run in every fintech API I ship.
⚡ On every refresh (automatic)
- 1. Verify signature, exp, aud, iss and algorithm before anything else
- 2. Delete the old jti and mint a fresh pair — one use per refresh token, no exceptions
- 3. On unknown jti: revoke the whole family and return 401 to force re-login
🪟 Session windows
- Sliding refresh: each rotation extends the session up to 7 days of activity
- Absolute cap: 30 days maximum even with activity, then a full re-authentication is required
- Idle timeout: 30 minutes without refreshes kills the session for sensitive fintech flows
🛡️ Operational hygiene
- Key rotation: rotate signing secrets with a dual-key grace period so old access tokens drain naturally
- Audit jti store: alert on reuse-detection events — each one is a probable token-theft incident
- Separate secrets: access and refresh tokens must never share a signing key
7. Common Mistakes That Get Fintechs Hacked
After reviewing several fintech auth implementations, the same flaws keep showing up. Check your codebase against both lists before shipping.
✅ Ship this
- • 15-minute access tokens + 7-day rotating refresh tokens
- • httpOnly, Secure, SameSite=strict cookies scoped to /auth/refresh
- • Reuse detection that revokes the entire token family
- • Explicit algorithms on every verify plus aud/iss validation
- • Server-side logout that deletes the jti from the store
- • Separate signing secrets for access and refresh tokens
❌ Never ship this
- • 24-hour access tokens with no refresh strategy
- • Tokens in localStorage or sessionStorage
- • Accepting alg none or omitting the algorithms option
- • Putting passwords, card numbers or PII in the JWT payload
- • Reusing one secret for access and refresh tokens
- • Logout that only clears the client cookie
Golden rule
A refresh token is a password that renews itself: single-use, invisible to JavaScript, and revoked at the first sign of reuse. If your implementation treats it as casually as an access token, rotation is just theater.
Conclusion
Short-lived access tokens limit the blast radius, rotation with reuse detection turns theft into an alarm, and httpOnly cookies keep long-lived credentials away from injected scripts. None of the three works alone — together they are the baseline OWASP expects from any API that moves money.
Start from the code in section 3, back it with a Redis jti store in production, and wire reuse-detection events into your alerting. Your future incident-response self will thank you — and if you want this implemented in your fintech API, you know where to find me.
The Recipe: Summary
Tokens
- • Access: 15 min, Bearer header
- • Refresh: 7 days, rotating
- • Reuse → revoke family
Storage
- • httpOnly + Secure cookie
- • SameSite=strict, /auth path
- • Access token in memory
Operations
- • Separate signing secrets
- • 30-day absolute session cap
- • Alert on reuse events



