Binding Refresh Tokens to a Device

A refresh token is the longest-lived credential most applications issue, and in its plain form it is a bearer token: whoever holds the string can mint access tokens until it expires. That makes every place the string might be copied — a log line, a device backup, an intercepted response, a support screenshot — a potential account takeover that persists for days.

Binding fixes the property that causes the problem. The token becomes useful only in combination with a private key the device cannot export, so a copied string is inert anywhere else. This guide implements binding with proof of possession at refresh, keeps rotation and reuse detection alongside it, and handles the legitimate situations that look exactly like theft. It is part of the Token Storage Patterns guide within Secure Authentication & Session Architecture.

Prerequisites

  • A client that can create a non-exportable key: platform secure enclave on mobile, or a non-extractable browser key
  • A token store recording the family, the bound key thumbprint and the spent state
  • Clock synchronisation good enough to validate short-lived proofs
  • A clean re-authentication path for the cases where the key is genuinely gone

Expected Outcomes

  • Refresh tokens that cannot be used from a device without the bound private key
  • A fresh, single-use proof required on every refresh
  • Rotation with family-wide revocation when a spent token reappears
  • Backup restore, storage clearing and multi-device use degrading to a clean login

Step 1: Create a Key the Application Cannot Read

// Browser: the private key is non-extractable by construction.
const keyPair = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  false,                                   // extractable: false — this is the whole point
  ['sign', 'verify'],
);
await idb.put('auth-key', keyPair);        // the handle is stored, the private key is not readable
const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
const thumbprint = await jwkThumbprint(jwk);

On mobile the equivalent is a key generated in the secure enclave or hardware-backed keystore, with biometric or device-credential protection if the product warrants it. In both cases the invariant is the same: application code can ask the key to sign, but cannot read the key.

What the Exfiltrated String Is Worth With a bearer refresh token, a copied string mints access tokens from anywhere until it expires or is revoked, and nothing about the request looks unusual. With a device-bound token, the same copied string produces a refusal because the request carries no valid proof from the bound key, and the attempt itself becomes a detectable signal. Bearer refresh token copied from a log, a backup, a proxy mints access tokens from any machine works until expiry or explicit revocation Nothing about the request looks unusual, so there is no signal to alert on. Device-bound refresh token the same string is copied just as easily refresh requires a signature from the key the key never left the enclave, so: refused The failed attempt is itself a signal worth alerting on and worth revoking the family for.

Step 2: Bind at Issue, Prove at Refresh

// Issue: record the key thumbprint with the token family.
await refreshTokens.put({
  familyId, tokenHash: sha256(refreshToken),
  cnf: { jkt: thumbprint },                   // confirmation claim: this token needs this key
  issuedAt: Date.now(), spentAt: null,
});

// Refresh: the client signs a short-lived proof over the request.
const proof = await signProof(keyPair.privateKey, {
  htm: 'POST', htu: 'https://api.example.com/auth/refresh',
  iat: Math.floor(Date.now() / 1000),
  jti: crypto.randomUUID(),                   // single use, server-tracked
});
await fetch('/auth/refresh', { method: 'POST', headers: { 'DPoP': proof }, credentials: 'include' });
# Server: verify the proof before the token is even looked at.
def refresh(request):
    proof = parse_proof(request.headers["DPoP"])
    if proof.htm != request.method or proof.htu != canonical_url(request):
        raise Unauthorized("proof not bound to this request")
    if abs(now() - proof.iat) > 60:
        raise Unauthorized("proof expired")
    if not replay_cache.add(proof.jti, ttl=120):
        raise Unauthorized("proof replayed")

    record = refresh_tokens.by_hash(sha256(request.cookies["rt"]))
    if record.cnf["jkt"] != thumbprint(proof.jwk):
        alerts.emit("refresh.key_mismatch", family=record.family_id)
        refresh_tokens.revoke_family(record.family_id)      # a copied token in the wild
        raise Unauthorized()
    return rotate(record)

Binding the proof to the method and URL matters as much as the signature: without it, a proof captured from one endpoint could be replayed against another. The single-use identifier closes the remaining replay window.

Every Field in the Proof Stops Something The method and URL fields stop a proof captured at one endpoint being replayed at another. The issue time bounds how long a captured proof stays usable. The unique identifier makes each proof single use. The key thumbprint ties the proof to the bound credential rather than to any key the caller happens to hold. method + URL a proof captured at one endpoint cannot be replayed at another issued-at time bounds the window in which a captured proof is worth anything unique identifier server-tracked, so the same proof cannot be presented twice key thumbprint ties the proof to the bound credential, not to any key at all

Step 3: Keep Rotation and Reuse Detection

Binding limits who can use a token; rotation tells you when someone tried.

def rotate(record):
    if record.spent_at is not None:                 # this token was already exchanged
        alerts.emit("refresh.reuse", family=record.family_id)
        refresh_tokens.revoke_family(record.family_id)
        raise Unauthorized("re-authenticate")

    refresh_tokens.mark_spent(record.id)            # atomic compare-and-set
    new_token = issue_refresh(record.user_id, record.family_id, record.cnf)
    return new_token, issue_access(record.user_id, cnf=record.cnf)

Note that the access token carries the confirmation claim too, so resource servers can require the same proof of possession. That closes the case where a short-lived access token is intercepted in transit and replayed within its lifetime.

Three Outcomes at the Refresh Endpoint A refresh carrying a valid, fresh, single-use proof from the bound key rotates the token and issues a new access token. A refresh whose proof is missing or signed by a different key indicates the token is being used elsewhere, so the family is revoked and an alert is emitted. A previously spent token reappearing indicates either theft or a lost response, so the family is revoked and the event recorded for review. Valid proof, unspent token → rotate new refresh token in the same family, new access token carrying the same confirmation claim Missing proof or a different key → revoke the family the token is being presented from somewhere that does not hold the bound key — treat as theft Spent token reappears → revoke the family, record for review usually a lost response and a client retry; occasionally theft — the audit entry is what tells them apart

Verification

# 1. A copied refresh token without the key must fail.
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.example.com/auth/refresh \
  -H "Cookie: rt=$STOLEN_REFRESH"                    # no proof header
# Expected: 401, plus a key_mismatch alert and family revocation.

# 2. A replayed proof must fail even with the right key.
curl -s -X POST https://api.example.com/auth/refresh -H "DPoP: $PROOF" -H "Cookie: rt=$RT" >/dev/null
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.example.com/auth/refresh \
  -H "DPoP: $PROOF" -H "Cookie: rt=$RT2"             # same proof, second use
# Expected: 401 — the identifier is in the replay cache.

# 3. A proof for a different endpoint must fail.
#    (Sign for /auth/refresh, present at /api/orders.)

Troubleshooting

Symptom Likely cause Fix
All refreshes fail after a client update Key handle stored under a name the new version does not read Version the storage key and fall back to re-authentication rather than an error
Intermittent failures on mobile networks Clock skew beyond the proof freshness window Allow a small skew tolerance and use server time in the response for correction
Users signed out after restoring a backup The enclave key does not restore with the backup, by design Detect the missing key and route to a clean login with an explanation
Reuse alerts on every flaky connection Client retries a refresh whose response was lost Make the client cache the in-flight refresh promise; keep the alert but classify it
Web clients cannot create a key Non-extractable key generation unavailable in that context Fall back to a strictly-scoped cookie with the shortest viable lifetime, and record the downgrade
Access tokens accepted without proof Resource servers ignore the confirmation claim Enforce the claim at the resource server too, not only at the refresh endpoint

Common Implementation Mistakes


Frequently Asked Questions

Does binding help if the whole device is compromised?

Not against code running on that device, and that is not its purpose. Binding removes the token’s value elsewhere: a string exfiltrated through a log, a backup, a shared clipboard, an intercepted response or a compromised proxy is useless without the private key, which never leaves the enclave. It converts a portable credential into a device-local one — a substantial reduction in blast radius, even though local malware remains a separate problem with separate answers.

What breaks for legitimate users?

Anything that moves the credential between environments: restoring a backup onto new hardware, clearing browser storage, or a user expecting a single sign-in to cover phone and laptop. Each is a genuine event, and each must degrade to a clean re-authentication rather than an error page. Detect the missing key, discard the bound token, and send the user through login with an explanation they can understand.

Is reuse detection still necessary with binding?

Yes. Binding narrows who can use a stolen token; reuse detection tells you theft occurred at all. Together they cover both halves: an attacker without the key cannot refresh, and a legitimate client replaying a spent token — which happens whenever a response is lost and the client retries — surfaces as an examinable event rather than a silently duplicated family.