Session token replay from another device: bind tokens to the client
For developers debugging account sessions that can be copied and reused from a different device. This runbook shows how to confirm replay, identify the missing or broken client-binding control, and roll out practical fixes with concrete app, proxy, and token-validation changes.
TL;DR — If a stolen session token works unchanged from a second device, your app is treating the token as a bearer secret with no effective client binding or replay detection. The fastest durable fix is: rotate session IDs on login, store a server-side fingerprint for the session (device-bound key or a low-friction client fingerprint), reject mismatches, and log/revoke on concurrent reuse from a new IP/UA tuple. Reading time: ~6 min
The scenario
It is Tuesday afternoon, support forwards you two screenshots from the same user account: one from the user’s laptop, one from an unknown Android device in another city, both active at the same time. You pull the auth logs and see the exact same session ID accepted by the API from two different IPs within minutes. Nothing is obviously broken in TLS, the JWT signature validates, and your WAF is green. The problem is simpler and worse: a copied token is replayable from another client because the app never tied that session to anything beyond possession of the token.
Symptoms
- Users report: "I logged out everywhere, but the attacker stayed signed in" or "I changed my password and another device still had access."
- Auth/API logs show the same session identifier or JWT
jtireused from different IPs or user agents:
2026-10-30T14:11:02Z auth-api INFO session accepted user_id=4812 sid=s_7f2c... ip=198.51.100.24 ua="Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7)"
2026-10-30T14:13:41Z auth-api INFO session accepted user_id=4812 sid=s_7f2c... ip=203.0.113.88 ua="Mozilla/5.0 (Linux; Android 15) Chrome/141.0.0.0"
- No server-side error is emitted; requests return normal success codes:
GET /api/account 200
POST /api/transfer 201
- If you replay the cookie or bearer token manually from another machine, it still works:
curl -i https://app.example.com/api/me \
-H 'Authorization: Bearer eyJhbGciOi...' \
-H 'User-Agent: curl/8.7.1'
HTTP/2 200
content-type: application/json
set-cookie: session=...; HttpOnly; Secure; SameSite=Lax
{"id":4812,"email":"user@example.com"}
- Logout only invalidates the browser copy, not all server-recognized uses of the token.
- Refresh tokens are long-lived and can mint new access tokens from a different device with no challenge.
Likely causes
| Cause | How common | Quick check |
|---|---|---|
| Session is a pure bearer token with no client binding or replay check | Very common | `rg -n "Authorization |
JWT validation checks signature/expiry only; ignores cnf, jti, or session record | Common | `rg -n "jwt.verify |
| Server-side session store does not persist client fingerprint/device key | Common | `redis-cli --scan --pattern 'session:*' |
| Reverse proxy/app trusts unstable headers and your binding key changes or is absent | Occasional | curl -s https://app.example.com/debug/headers |
| Refresh token rotation/reuse detection is missing | Occasional | `rg -n "refresh.*rotate |
| Mobile/native clients cannot support the chosen binding method, so binding was disabled globally | Less common | `rg -n "DPoP |
Step-by-step diagnosis
- Check whether the same token works from a second client.
TOKEN='eyJhbGciOi...'
curl -i https://app.example.com/api/me -H "Authorization: Bearer $TOKEN"
Then run the same command from another machine, VPN exit, or container with a different UA:
docker run --rm curlimages/curl:8.7.1 \
-i https://app.example.com/api/me \
-H "Authorization: Bearer $TOKEN" \
-H 'User-Agent: replay-test/1.0'
If both return HTTP/2 200, this is your problem. Jump to Fixes → Session is a pure bearer token with no client binding or replay check.
- Inspect what the token contains and what your app validates.
python - <<'PY'
import base64, json
jwt='eyJhbGciOi...'
parts=jwt.split('.')
for i,p in enumerate(parts[:2]):
p += '=' * (-len(p) % 4)
print(i, json.dumps(json.loads(base64.urlsafe_b64decode(p)), indent=2))
PY
If you see no jti, no sid, and no cnf claim, or your code only calls signature/expiry verification, the token is not bindable as implemented. Jump to Fixes → JWT validation checks signature/expiry only.
- Check whether the server stores any session-bound client attributes.
redis-cli --scan --pattern 'session:*' | head -5 | xargs -r -n1 redis-cli HGETALL
If records contain only user_id, exp, scope and no device_key_thumbprint, fingerprint_hash, ua_hash, or last_seen_ip_prefix, you have nowhere to enforce binding. Jump to Fixes → Server-side session store does not persist client fingerprint/device key.
- Verify the app sees stable client metadata through the proxy. If you have a debug endpoint in non-prod, hit it through the public edge:
curl -s https://app.example.com/debug/headers | jq .
What indicates the problem: X-Forwarded-For is missing, duplicated, or always the proxy IP; User-Agent is stripped; or your app uses req.ip without trusted proxy config. Jump to Fixes → Reverse proxy/app trusts unstable headers.
- Test refresh token replay. Use one refresh token twice from two environments:
REFRESH='r1....'
curl -s https://app.example.com/oauth/token -d "grant_type=refresh_token&refresh_token=$REFRESH"
curl -s https://app.example.com/oauth/token -d "grant_type=refresh_token&refresh_token=$REFRESH"
If both succeed and mint valid access tokens, refresh reuse detection is missing. Jump to Fixes → Refresh token rotation/reuse detection is missing.
- Confirm whether your clients can support strong binding. For browser apps, check whether you can hold a non-exportable WebCrypto key; for native apps, whether you can use platform keystore/secure enclave. If product requirements forced you to disable DPoP or device keys globally for one client type, but you reused that auth path for all clients, jump to Fixes → Mobile/native clients cannot support the chosen binding method.
View as text
[Attacker Device] ---- 3. replay token ----> [API]
^
|
2. copied bearer |
[Victim Browser] ---- token/cookie ----> [Leaked via XSS/log/proxy]
|
v
[No client binding]Fixes
Session is a pure bearer token with no client binding or replay check
For server-managed sessions, bind the session record to a client-held key if possible; otherwise bind to a low-friction fingerprint and treat mismatches as high risk, not identity proof.
Minimal server-side session schema:
{
"sid": "s_7f2c...",
"user_id": 4812,
"device_key_thumbprint": "sha256:3b7d...",
"fingerprint_hash": "sha256:91ac...",
"ua_hash": "sha256:7d2e...",
"last_seen_ip_prefix": "198.51.100.0/24",
"rotated_at": "2026-10-30T14:00:00Z",
"expires_at": "2026-10-30T22:00:00Z"
}
On login:
- rotate session ID;
- generate/store binding material;
- reject requests where the presented proof/fingerprint mismatches the stored record.
Example fingerprint input to hash server-side:
const fpSource = [
req.get('sec-ch-ua') || '',
req.get('sec-ch-ua-platform') || '',
req.get('accept-language') || '',
req.get('user-agent') || ''
].join('|')
const fingerprintHash = sha256(fpSource)
Trade-off: UA/IP-based binding is weaker and can false-positive on browser upgrades, mobile carrier NAT, or privacy tooling. Prefer proof-of-possession with a client key over IP pinning.
Verify it worked:
curl -i https://app.example.com/api/me -H "Authorization: Bearer $TOKEN" -H 'User-Agent: replay-test/1.0'
Expect 401 or 403 with a body like {"error":"client_binding_failed"}.
JWT validation checks signature/expiry only; ignores cnf, jti, or session record
If you use JWT access tokens, validate more than signature and exp. Require jti and either a cnf claim (confirmation key thumbprint) or a sid that maps to a server-side session record.
Example Node validation logic:
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com'
})
if (!payload.jti) throw new Error('missing_jti')
if (!payload.sid) throw new Error('missing_sid')
const session = await redis.hGetAll(`session:${payload.sid}`)
if (!session || session.revoked_at) throw new Error('revoked_session')
if (payload.cnf?.jkt && payload.cnf.jkt !== req.clientKeyThumbprint) {
throw new Error('cnf_mismatch')
}
If using DPoP-style proof, require a proof header and compare its public key thumbprint to cnf.jkt.
Verify it worked:
rg -n "missing_jti|missing_sid|cnf_mismatch|revoked_session" src/
Then replay a token without proof and expect 401.
Server-side session store does not persist client fingerprint/device key
Add fields to the session store and enforce them in middleware before route handlers.
Redis example:
redis-cli HSET session:s_7f2c user_id 4812 device_key_thumbprint sha256:3b7d fingerprint_hash sha256:91ac ua_hash sha256:7d2e
Express-style middleware:
app.use(async (req, res, next) => {
const sid = req.cookies.session || bearerSid(req)
const s = await redis.hGetAll(`session:${sid}`)
if (!s.user_id) return res.status(401).json({error:'invalid_session'})
const uaHash = sha256(req.get('user-agent') || '')
if (s.ua_hash && s.ua_hash !== uaHash) {
await redis.hSet(`session:${sid}`, 'revoked_at', new Date().toISOString())
return res.status(401).json({error:'client_binding_failed'})
}
next()
})
Trade-off: hard revocation on mismatch is secure but can annoy users after browser updates. A softer pattern is step-up auth for sensitive routes and immediate revoke only on impossible travel + UA change + refresh reuse.
Verify it worked:
redis-cli HGETALL session:s_7f2c
You should see the new binding fields populated.
Reverse proxy/app trusts unstable headers and your binding key changes or is absent
Do not bind to raw req.ip unless trusted proxy handling is correct. Configure the proxy to pass canonical headers and configure the app to trust only your edge hops.
nginx:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header User-Agent $http_user_agent;
Express behind one proxy tier:
app.set('trust proxy', 1)
If you are behind multiple fixed proxies, set explicit CIDRs instead of true.
Realistic bad signal from a debug endpoint:
{
"ip":"10.0.12.14",
"x-forwarded-for":null,
"user-agent":null
}
That means your binding inputs are garbage or empty.
Verify it worked:
curl -s https://app.example.com/debug/headers | jq '{ip,xff: ."x-forwarded-for", ua: ."user-agent"}'
Expect the public client chain and a non-empty UA.
Refresh token rotation/reuse detection is missing
Rotate refresh tokens on every use and track token families. If an old refresh token is used again, revoke the whole family and force re-auth.
Pseudo-schema:
{
"refresh_id": "rt_123",
"family_id": "fam_abc",
"parent_id": "rt_122",
"used_at": "2026-10-30T14:10:00Z",
"revoked_at": null
}
On refresh:
-- mark current token used, create child token, same family
On reuse of an already-used token:
UPDATE refresh_tokens SET revoked_at = now() WHERE family_id = $1 AND revoked_at IS NULL;
Return a distinct error so you can alert on it:
{"error":"refresh_token_reuse_detected"}
Verify it worked:
curl -s https://app.example.com/oauth/token -d "grant_type=refresh_token&refresh_token=$REFRESH"
Second use of the same token should fail with 400 or 401 and the reuse error.
Mobile/native clients cannot support the chosen binding method, so binding was disabled globally
Split browser and native client auth policies. Do not drop to weakest-common-denominator bearer semantics for every client.
Recommended pattern:
- Browser SPA/web app: non-exportable WebCrypto key + proof-of-possession for refresh/access where feasible.
- Native mobile: platform keystore-backed device key.
- Legacy clients: short-lived access token, rotating refresh token, server-side session record, aggressive anomaly detection.
Config example:
{
"clients": {
"web": {"require_pop": true, "access_ttl_sec": 600, "refresh_rotation": true},
"ios": {"require_device_key": true, "access_ttl_sec": 600, "refresh_rotation": true},
"legacy": {"require_pop": false, "access_ttl_sec": 300, "refresh_rotation": true}
}
}
Verify it worked:
jq '.clients' config/auth.json
You should see per-client policy instead of one global bearer policy.
Prevention
- Add a replay detector on
sid/jtireused from a new IP prefix + UA hash within a short window.
SELECT sid, COUNT(DISTINCT ip_prefix || '|' || ua_hash) AS variants
FROM auth_events
WHERE ts > now() - interval '15 minutes'
GROUP BY sid
HAVING COUNT(DISTINCT ip_prefix || '|' || ua_hash) > 1;
- Emit structured auth logs with
sid,jti,family_id,ua_hash,ip_prefix,binding_result.
{"event":"session_check","sid":"s_7f2c","jti":"j_91aa","binding_result":"cnf_mismatch","ip_prefix":"203.0.113.0/24"}
- Add CI tests that replay a captured token from a second client profile and expect failure.
npm test -- session-replay.spec.ts
Test assertion: second request with same token and different proof/UA returns 401.
- Pin short token lifetimes and mandatory refresh rotation in config, not code comments.
{"access_ttl_sec":600,"refresh_ttl_sec":2592000,"refresh_rotation":true,"require_jti":true}
- Alert on refresh reuse and bulk-revoke the token family automatically.
alert: RefreshTokenReuse
expr: sum(rate(auth_refresh_reuse_total[5m])) > 0
for: 0m
labels: {severity: page}
- Stop logging raw bearer tokens and cookies anywhere in app/proxy logs.
log_format main '$remote_addr - $request_method $uri $status';
If your current format includes $http_authorization or cookie headers, remove them before the next deploy.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI