Modern apps often use a browser SPA, a mobile app, and a backend API. The security weak spot is usually not login itself, but what happens after login: stolen access tokens, replayed refresh tokens, and long-lived sessions that are hard to revoke safely.
In this guide, we will build a practical anti-replay token architecture for 2026 using three layers:
- Short-lived access tokens (5 to 10 minutes)
- Refresh token rotation with replay detection
- DPoP proof-of-possession so stolen bearer tokens are less useful
Stack: Node.js (Express), Redis, JWT, and a small browser DPoP client.
Threat Model: What We Are Defending Against
Typical production incidents include:
- Access token leaked from logs, browser extensions, or XSS
- Refresh token exfiltrated from a compromised client
- Token replay from a different device, IP, or region
If your API accepts bearer tokens without binding them to a client key, any attacker with the token can replay it until expiry.
Architecture Overview
- User signs in and gets an access token + refresh token family ID.
- Client generates a DPoP keypair (private key stays local).
- Every API call includes a DPoP proof JWT tied to method + URL.
- When access token expires, refresh endpoint rotates refresh token.
- If an old refresh token is reused, invalidate the whole token family.
1) Access Token with Confirmation Claim
The access token should include a confirmation (cnf) claim with the JWK thumbprint used by DPoP.
import jwt from "jsonwebtoken";
function signAccessToken({ sub, scope, jkt }) {
return jwt.sign(
{
sub,
scope,
cnf: { jkt }, // bind token to DPoP public key thumbprint
typ: "at+jwt"
},
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: "8m", issuer: "7tech-auth", audience: "7tech-api" }
);
}
Even if this token leaks, the attacker still needs the matching private key to create valid DPoP proofs.
2) DPoP Proof Verification Middleware
The client sends:
Authorization: DPoP <access_token>DPoP: <proof_jwt>
Server validates signature, htu, htm, iat freshness, and replay of jti.
import { jwtVerify, importJWK, calculateJwkThumbprint } from "jose";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
export async function verifyDpop(req, res, next) {
try {
const proof = req.header("DPoP");
if (!proof) return res.status(401).json({ error: "missing_dpop" });
const { payload, protectedHeader } = await jwtVerify(proof, async (header, token) => {
const jwk = token?.payload?.jwk;
if (!jwk) throw new Error("missing_jwk");
return importJWK(jwk, protectedHeader.alg);
}, { typ: "dpop+jwt" });
const htm = req.method.toUpperCase();
const htu = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
if (payload.htm !== htm || payload.htu !== htu) {
return res.status(401).json({ error: "dpop_binding_failed" });
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - payload.iat) > 120) {
return res.status(401).json({ error: "dpop_stale" });
}
// replay protection on jti for 2 minutes
const replayKey = `dpop:jti:${payload.jti}`;
const ok = await redis.set(replayKey, "1", "EX", 120, "NX");
if (ok !== "OK") return res.status(401).json({ error: "dpop_replay" });
const jkt = await calculateJwkThumbprint(payload.jwk, "sha256");
req.dpop = { jkt };
next();
} catch (e) {
return res.status(401).json({ error: "invalid_dpop", detail: e.message });
}
}
Important production note
If you run multiple API nodes, store replay state in Redis (or another centralized cache), not local memory.
3) Enforce Token and DPoP Binding Together
After verifying access JWT, compare its cnf.jkt with the DPoP key thumbprint from middleware.
function requireBoundAccessToken(req, res, next) {
const token = req.accessTokenPayload;
const tokenJkt = token?.cnf?.jkt;
const dpopJkt = req.dpop?.jkt;
if (!tokenJkt || !dpopJkt || tokenJkt !== dpopJkt) {
return res.status(401).json({ error: "token_not_bound_to_dpop_key" });
}
next();
}
This blocks simple bearer replay attacks.
4) Refresh Token Rotation with Family Revocation
Refresh rotation means every use returns a brand new refresh token and invalidates the old one. If an already-used refresh token appears again, treat it as theft and revoke the whole family.
import { randomUUID } from "crypto";
// Redis schema:
// rt:{tokenId} => { userId, familyId, used:false, exp }
// rtfamily:{familyId}:revoked => 0/1
async function rotateRefreshToken(oldTokenId) {
const key = `rt:${oldTokenId}`;
const dataRaw = await redis.get(key);
if (!dataRaw) throw new Error("invalid_refresh");
const data = JSON.parse(dataRaw);
const revoked = await redis.get(`rtfamily:${data.familyId}:revoked`);
if (revoked === "1") throw new Error("family_revoked");
if (data.used) {
await redis.set(`rtfamily:${data.familyId}:revoked`, "1", "EX", 60 * 60 * 24 * 30);
throw new Error("refresh_replay_detected");
}
data.used = true;
await redis.set(key, JSON.stringify(data), "EX", 60 * 60 * 24 * 30);
const newId = randomUUID();
const newRecord = { userId: data.userId, familyId: data.familyId, used: false };
await redis.set(`rt:${newId}`, JSON.stringify(newRecord), "EX", 60 * 60 * 24 * 30);
return { userId: data.userId, familyId: data.familyId, newTokenId: newId };
}
5) Client-Side DPoP Key Lifecycle
- Generate keypair on first login.
- Store private key in platform-secure storage (not plain localStorage).
- Rotate DPoP key periodically (for example every 30 days) and on suspicious signals.
- On logout, wipe key and refresh token data.
In browsers, use WebCrypto + IndexedDB. In mobile, use Keychain/Keystore.
Operational Defenses That Matter
- Rate-limit refresh endpoint per user and IP.
- Geo/ASN anomaly checks for token use.
- Session event logs for login, refresh, replay detection, and family revocation.
- Instant admin revoke endpoint for high-risk accounts.
Minimal Rollout Plan
- Add refresh rotation first.
- Add DPoP in report-only mode and log validation errors.
- Enable DPoP enforcement for internal users.
- Gradually enforce for all clients.
- Track replay and false-positive metrics weekly.
Final Checklist
- Access tokens under 10 minutes
- Refresh rotation enabled
- Replay detection and family revocation live
- DPoP proof verification with central replay cache
cnf.jktbinding enforced on every protected API route
Security in 2026 is less about one magic library and more about layered controls. With token binding + rotation + replay intelligence, you make token theft significantly harder to monetize and faster to contain when incidents happen.

Leave a Reply