7 Powerful Passkeys + Token Binding to Stop Session Replay
Engineering teams are migrating auth stacks fast—yet session replay and refresh-token theft remain a top real-world failure mode. The fix isn’t “more MFA prompts.” The fix is proof: proof the user is present (passkeys), and proof the token is being used by the same client that originally received it (token binding / sender-constrained tokens).
This post gives you a security-review-ready, code-forward blueprint for passkeys + token binding that actually survives modern attack paths: reverse proxies, stolen cookies, refresh token reuse, and “works-on-my-machine” logging that’s useless during incidents.

If you want help validating your rollout (or you want a third-party to test it the way attackers do), start with a quick external baseline, then harden systematically:
- Risk assessment services: https://www.pentesttesting.com/risk-assessment-services/
- Remediation services: https://www.pentesttesting.com/remediation-services/
- DFIR / forensics help: https://www.pentesttesting.com/digital-forensic-analysis-services/
1) Threat model in 10 minutes
Here are the attack paths your passkeys + token binding rollout must assume:
A. Session replay (stolen cookie or bearer token)
What happens: attacker steals a session cookie (XSS, browser malware, proxy logs, misconfigured CDN) or a bearer access token (mobile logs, debug traces) and replays it from their own client.
Why it works: your server sees a valid token/cookie and trusts it—because nothing ties the token to the original client.
B. Refresh-token theft (long-lived persistence)
What happens: attacker steals a refresh token and keeps minting new access tokens quietly.
Why it works: refresh tokens are often:
- long-lived,
- stored unsafely (localStorage, logs, DB dumps),
- not rotated, and/or
- not monitored for “reuse” signals.
C. Refresh-token reuse (token family replay)
What happens: you rotate refresh tokens—but the attacker reuses an older refresh token. If you don’t treat reuse as an incident, the attacker can “race” your legitimate user and win.
Why it works: rotation without reuse detection + family revocation is a false sense of security.
D. Reverse proxies & edge layers (token leakage + confusion)
What happens: headers are logged, cookies are forwarded across subdomains, or tokens bleed into upstream layers.
Why it works: security boundaries are blurry unless you enforce consistent rules at the edge.
E. Device malware (can still ride your session)
What happens: malware runs in the user’s context and can call your APIs.
Reality check: no auth scheme “beats” a fully compromised endpoint—but strong binding still reduces remote replay and improves detection.
2) What “token binding” means in 2026 (practical definition)
When we say token binding in this post, we mean:
- Sender-constrained access tokens (example: DPoP-style proof-of-possession)
→ even if the access token is stolen, it’s useless without the client’s private key. - Device-bound session keys (example: a client-held key signs requests or derives a per-device session secret)
→ raises the bar for replay and improves attribution.
This is different from “bearer tokens,” which are valid for anyone who holds them.
3) Reference architecture (the version you can actually ship)
Here’s the pattern that works for most modern web apps:
Login:
- User signs in using passkeys (WebAuthn).
- Server issues a short-lived access token (5–15 minutes) and a rotating refresh token.
Access token:
- Sender-constrained using a DPoP-style proof (token binding concept).
- Replay protection via
jti(unique token id) caching for high-risk endpoints.
Refresh token:
- Rotated on every use.
- Stored in an HttpOnly, Secure cookie (web) or OS secure storage (mobile).
- Reuse detection: if an old refresh token is used again, revoke the entire token family + sessions.
Session cookie (optional but common):
- If you also keep server sessions, use
__Host-cookies, strict scoping, and bind session to device key where possible.
Quick diagram
Passkey Login (WebAuthn)
|
v
Issue tokens:
- Access (short-lived, sender-constrained)
- Refresh (rotating, reuse-detected)
|
v
Each API request includes:
Authorization: DPoP-bound access token
DPoP: proof JWT (method+url+jti+iat)4) Passkeys (WebAuthn) implementation (Node.js, production-friendly)
Below is a practical Node.js implementation using a battle-tested WebAuthn helper library.
4.1 Install
npm i express cookie-parser @simplewebauthn/server @simplewebauthn/types
npm i jose ioredis zod4.2 Minimal data model (concept)
users: id, email, created_atwebauthn_credentials: user_id, credential_id, public_key, counter, transports, created_at
4.3 Generate registration options (server)
import express from "express";
import cookieParser from "cookie-parser";
import Redis from "ioredis";
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
const app = express();
app.use(express.json());
app.use(cookieParser());
const redis = new Redis(process.env.REDIS_URL);
const RP_ID = "yourdomain.com";
const ORIGIN = "https://yourdomain.com";
app.post("/auth/passkeys/register/options", async (req, res) => {
const { userId, email } = req.body;
// 1) Pull existing credentials for exclude list
const existingCredIds = await getCredentialIdsForUser(userId); // implement
const options = generateRegistrationOptions({
rpName: "Your App",
rpID: RP_ID,
userID: String(userId),
userName: email,
timeout: 60_000,
attestationType: "none",
excludeCredentials: existingCredIds.map((id) => ({ id, type: "public-key" })),
authenticatorSelection: {
userVerification: "preferred",
residentKey: "preferred",
},
});
// 2) Store challenge (short TTL)
await redis.setex(`webauthn:reg:chal:${userId}`, 300, options.challenge);
res.json(options);
});4.4 Verify registration response (server)
app.post("/auth/passkeys/register/verify", async (req, res) => {
const { userId, response } = req.body;
const expectedChallenge = await redis.get(`webauthn:reg:chal:${userId}`);
if (!expectedChallenge) return res.status(400).json({ error: "challenge_expired" });
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: false,
});
if (!verification.verified) return res.status(400).json({ error: "not_verified" });
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
await saveCredential({
userId,
credentialID,
credentialPublicKey,
counter,
});
// Clean up
await redis.del(`webauthn:reg:chal:${userId}`);
res.json({ ok: true });
});4.5 Login options + verify (server)
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
app.post("/auth/passkeys/login/options", async (req, res) => {
const { userId } = req.body;
const creds = await getCredentialsForUser(userId); // implement
const options = generateAuthenticationOptions({
rpID: RP_ID,
allowCredentials: creds.map((c) => ({ id: c.credentialID, type: "public-key" })),
userVerification: "preferred",
timeout: 60_000,
});
await redis.setex(`webauthn:auth:chal:${userId}`, 300, options.challenge);
res.json(options);
});
app.post("/auth/passkeys/login/verify", async (req, res) => {
const { userId, response } = req.body;
const expectedChallenge = await redis.get(`webauthn:auth:chal:${userId}`);
if (!expectedChallenge) return res.status(400).json({ error: "challenge_expired" });
const cred = await getCredentialById(response.id); // implement
if (!cred) return res.status(400).json({ error: "unknown_credential" });
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
authenticator: {
credentialID: cred.credentialID,
credentialPublicKey: cred.credentialPublicKey,
counter: cred.counter,
},
requireUserVerification: false,
});
if (!verification.verified) return res.status(401).json({ error: "invalid_passkey" });
// Update counter to prevent cloned-authenticator replay
await updateCredentialCounter(cred.id, verification.authenticationInfo.newCounter);
await redis.del(`webauthn:auth:chal:${userId}`);
// Next: issue access + refresh tokens (see sections below)
res.json({ ok: true });
});Why passkeys matter here: passkeys reduce credential phishing and many MFA bypass patterns—so you stop feeding attackers “valid logins” that create legitimate sessions.
5) Rotating refresh tokens (the part most teams get wrong)
5.1 Refresh token schema (simple + effective)
Table: refresh_tokens
id(uuid)user_idfamily_id(uuid) → all rotations share a familytoken_hash(hash of refresh token, never store raw)issued_atexpires_atrevoked_at(nullable)replaced_by_id(nullable)ip_first_seen,ua_first_seen(optional, for detection)
5.2 Issue refresh token (server)
import crypto from "crypto";
function sha256base64(input) {
return crypto.createHash("sha256").update(input).digest("base64url");
}
function newOpaqueToken(bytes = 32) {
return crypto.randomBytes(bytes).toString("base64url");
}
async function issueRefreshToken({ userId, familyId, ip, ua }) {
const raw = newOpaqueToken(48);
const tokenHash = sha256base64(raw);
const record = await db.refresh_tokens.insert({
user_id: userId,
family_id: familyId,
token_hash: tokenHash,
issued_at: new Date(),
expires_at: new Date(Date.now() + 30 * 24 * 3600 * 1000), // example 30 days
ip_first_seen: ip,
ua_first_seen: ua,
});
return { raw, id: record.id, familyId };
}5.3 Rotate on refresh (server) + reuse detection
app.post("/auth/token/refresh", async (req, res) => {
const raw = req.cookies.refresh_token;
if (!raw) return res.status(401).json({ error: "missing_refresh" });
const tokenHash = sha256base64(raw);
const existing = await db.refresh_tokens.findByHash(tokenHash);
if (!existing) return res.status(401).json({ error: "invalid_refresh" });
// If revoked or already replaced -> reuse attempt (incident signal)
if (existing.revoked_at || existing.replaced_by_id) {
await revokeFamily(existing.family_id);
await revokeUserSessions(existing.user_id);
return res.status(401).json({ error: "refresh_reuse_detected" });
}
// Rotate
const next = await issueRefreshToken({
userId: existing.user_id,
familyId: existing.family_id,
ip: req.ip,
ua: req.get("user-agent") || "",
});
await db.refresh_tokens.update(existing.id, {
replaced_by_id: next.id,
revoked_at: new Date(),
});
const accessToken = await issueAccessTokenSenderConstrained(existing.user_id, req); // next section
setRefreshCookie(res, next.raw);
return res.json({ access_token: accessToken });
});
function setRefreshCookie(res, raw) {
// Prefer __Host- prefix: must be Secure + Path=/ and no Domain attribute.
res.cookie("refresh_token", raw, {
httpOnly: true,
secure: true,
sameSite: "Strict",
path: "/",
});
}Key principle: refresh rotation without reuse detection is not enough. Treat reuse as a security event.
6) Token binding with DPoP-style proofs (sender-constrained access)
This is where passkeys + token binding becomes “session replay resistant.”
6.1 Client: create a DPoP key (browser)
// Run once per device/session; persist in IndexedDB or memory depending on your risk model.
export async function generateDpopKeyPair() {
return crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true,
["sign", "verify"]
);
}6.2 Client: create a DPoP proof JWT
import { SignJWT, exportJWK } from "jose";
export async function makeDpopProof({ keyPair, htu, htm }) {
const jwk = await exportJWK(keyPair.publicKey);
const now = Math.floor(Date.now() / 1000);
// jti is anti-replay for proofs
const jti = crypto.randomUUID();
return await new SignJWT({ htu, htm, jti })
.setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk })
.setIssuedAt(now)
.setExpirationTime(now + 60) // short-lived proof
.sign(keyPair.privateKey);
}6.3 Client: call API with bound access token + proof
const proof = await makeDpopProof({
keyPair,
htu: "https://yourdomain.com/api/v1/orders",
htm: "GET",
});
const resp = await fetch("/api/v1/orders", {
method: "GET",
headers: {
Authorization: `DPoP ${accessToken}`,
DPoP: proof,
},
credentials: "include",
});6.4 Server: verify DPoP proof (Node.js middleware)
import { jwtVerify, calculateJwkThumbprint } from "jose";
async function verifyDpop(req, res, next) {
const proof = req.get("DPoP");
if (!proof) return res.status(401).json({ error: "missing_dpop_proof" });
const htm = req.method.toUpperCase();
const htu = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
// NOTE: jwtVerify here needs the public key; we extract jwk from header first.
// In production, validate alg, typ, and jwk shape strictly.
const { protectedHeader } = await jwtVerify(proof, async (header) => {
if (!header.jwk) throw new Error("missing_jwk");
return await importJWKStrict(header.jwk); // implement: reject weak/unknown keys
}, {
maxTokenAge: "60s",
});
// Re-verify payload claims
const { payload } = await jwtVerify(proof, await importJWKStrict(protectedHeader.jwk), {
maxTokenAge: "60s",
});
if (payload.htm !== htm) return res.status(401).json({ error: "dpop_htm_mismatch" });
if (payload.htu !== htu) return res.status(401).json({ error: "dpop_htu_mismatch" });
// Anti-replay on jti (especially for high-risk endpoints)
const jti = payload.jti;
if (!jti) return res.status(401).json({ error: "missing_jti" });
const key = `dpop:jti:${jti}`;
const ok = await redis.set(key, "1", "NX", "EX", 90);
if (!ok) return res.status(401).json({ error: "dpop_replay_detected" });
// Thumbprint used to bind the access token (cnf)
req.dpopThumbprint = await calculateJwkThumbprint(protectedHeader.jwk);
return next();
}6.5 Server: issue access token bound to client key thumbprint
Bind your access token to the DPoP key thumbprint so stolen access tokens can’t be replayed from a different client.
import { SignJWT } from "jose";
async function issueAccessTokenSenderConstrained(userId, req) {
const now = Math.floor(Date.now() / 1000);
// In real flows, you learn thumbprint at token issuance time.
// For example: after passkey login, client sends a one-time proof to register its DPoP key.
const cnf = req.dpopThumbprint ? { jkt: req.dpopThumbprint } : undefined;
return await new SignJWT({
sub: String(userId),
cnf, // confirmation claim for sender constraint
scope: "api",
})
.setProtectedHeader({ alg: "HS256", typ: "at+jwt" }) // example; use your signing approach
.setIssuedAt(now)
.setExpirationTime(now + 10 * 60) // 10 minutes
.setJti(crypto.randomUUID())
.sign(getAccessTokenSigningKey()); // implement
}6.6 Server: enforce binding on each request
function enforceAccessTokenBinding(req, accessTokenPayload) {
const bound = accessTokenPayload.cnf?.jkt;
if (!bound) throw new Error("token_not_bound");
if (!req.dpopThumbprint) throw new Error("missing_dpop_context");
if (bound !== req.dpopThumbprint) throw new Error("binding_mismatch");
}Result: an attacker who steals a bearer access token can’t replay it without the client key.
7) Logging & telemetry (make incidents diagnosable without collecting secrets)
If you deploy passkeys + token binding but your logs can’t answer incident questions, you still lose.
7.1 What to log (high signal, no secrets)
Log events, not raw tokens:
auth.passkey.registered(user_id, device_id, credential_id_hash)auth.passkey.login_succeeded(user_id, device_id)auth.token.issued(user_id, token_type, bound=true/false)auth.refresh.rotated(user_id, family_id, refresh_id)auth.refresh.reuse_detected(user_id, family_id, ip, ua)auth.dpop.replay_detected(user_id? if known, jti)auth.binding.mismatch(user_id? if known)auth.session.revoked(reason)
7.2 Suggested JSON log shape
{
"ts": "2026-01-25T10:15:12Z",
"event": "auth.refresh.reuse_detected",
"request_id": "req_01J...",
"user_id": "12345",
"session_id": "sess_9c2...",
"device_id": "dev_f1a...",
"ip": "203.0.113.10",
"ua": "Mozilla/5.0 ...",
"family_id": "fam_7b1...",
"severity": "high"
}7.3 Link your logs to a forensics-ready mindset
If you want a deeper blueprint for audit trails + request IDs + retention, pair this rollout with:
- Forensics-ready logging patterns (Cyber Rely): https://www.cybersrely.com/forensics-ready-saas-logging-audit-trails/
And if you suspect compromise during rollout (token reuse spikes, unknown devices): - DFIR / forensics: https://www.pentesttesting.com/digital-forensic-analysis-services/
8) Engineering acceptance tests (what your pentest will try)
Use these as “definition of done” for passkeys + token binding.
A. Passkeys / WebAuthn
- Register passkey with correct RP ID/origin enforcement.
- Reject replayed WebAuthn challenges (challenge must be single-use).
- Reject cloned authenticator attempts (counter regression detection).
- Verify sign-in works with and without platform authenticators (as intended).
B. Access token binding / DPoP
- Missing
DPoPheader → request rejected. htumismatch (proxy path differences) → rejected.htmmismatch → rejected.- Reuse same
jti→ rejected as replay. - Access token
cnf.jktmismatch to proof thumbprint → rejected.
C. Refresh rotation & reuse
- Refresh rotates and invalidates old token.
- Old refresh token used again → triggers:
- family revocation,
- session revocation,
- security event log.
D. Session replay attempts
- Copy cookies/access token into a different browser profile → should fail (binding) or get revoked quickly (reuse detection).
- Attempt “parallel refresh” race conditions → only one wins; others are treated as suspicious.
E. “What your pentest will try” checklist
If you engage a formal assessment later, expect testers to target:
- XSS paths to steal cookies/tokens
- cookie scope/domain mistakes
- refresh endpoints lacking strict anti-replay
- weak device binding assumptions
- logging gaps (can’t correlate request IDs)
- reverse proxy header/caching leaks
For a structured third-party validation:
- Risk assessment: https://www.pentesttesting.com/risk-assessment-services/
- Remediation help after findings: https://www.pentesttesting.com/remediation-services/
9) Ship plan: staged rollout, fallback, monitoring, playbooks
Phase 0: Baseline visibility (1–2 sprints)
- Add request IDs everywhere.
- Add auth event logging (no secrets).
- Add dashboards for refresh usage, failures, reuse detections.
Phase 1: Refresh rotation + reuse detection
- Ship rotation behind a feature flag.
- Turn on “reuse detection” alerts.
- Add family/session revocation playbook.
Phase 2: Passkeys as an option (don’t break users)
- Offer passkeys as a second factor replacement path.
- Add recovery flow and multi-device registration.
- Monitor login success rate.
Phase 3: Token binding for high-risk routes first
- Start with admin endpoints and money-moving actions.
- Enforce DPoP + binding only where it matters most.
- Expand gradually.
Phase 4: Hardening + incident readiness
- Rate-limit refresh and login.
- Add anomaly rules (new geo + refresh reuse, unusual UA shifts).
- Document “what to do when reuse is detected.”
Free Website Vulnerability Scanner tool page (scan workflow)

Sample report to check Website Vulnerability (from the tool)

Related reading (internal)
If you’re building an engineering playbook around secure identity and “proof-based” auth, these Cyber Rely posts pair well with passkeys + token binding:
- OAuth abuse controls (stop silent token persistence): https://www.cybersrely.com/oauth-abuse-consent-phishing-playbook/
- Secure Web Push patterns (abuse-resistant tokens + rate limits mindset): https://www.cybersrely.com/secure-web-push-chrome-143/
- Forensics-ready SaaS logging (diagnosable incidents): https://www.cybersrely.com/forensics-ready-saas-logging-audit-trails/
- Secrets as Code patterns (credential lifecycle discipline): https://www.cybersrely.com/secrets-as-code-patterns/
- Supply-chain CI hardening wins (prove integrity continuously): https://www.cybersrely.com/supply-chain-ci-hardening-2026/
And if you want structured help beyond a DIY rollout:
- Risk Assessment Services: https://www.pentesttesting.com/risk-assessment-services/
- Remediation Services: https://www.pentesttesting.com/remediation-services/
- Digital Forensic Analysis (DFIR): https://www.pentesttesting.com/digital-forensic-analysis-services/
🔐 Frequently Asked Questions (FAQs)
Find answers to commonly asked questions about Passkeys + Token Binding to Stop Session Replay.