Device-bound sessions
Tie each session to a key that never leaves the browser (DPoP, RFC 9449), so a stolen refresh cookie is useless on its own.
Device-bound sessions
A refresh cookie is a bearer credential: whoever holds it is the user. With device binding the browser also holds a key it cannot export, and the service checks a short signed proof from that key every time the cookie is used. Malware, a leaked backup or a proxy log that captures the cookie gets nothing without the key.
This is DPoP (RFC 9449). The SDK does all of it; there is nothing to build.
Turn it on
Dashboard → Sessions → Device binding, or the backend API:
PATCH /v1/instance/device_binding { "mode": "optional" }
GET /v1/instance/device_binding → { "mode": "optional" }| Mode | Behavior |
|---|---|
off (default) |
Proofs are ignored. Nothing is bound. |
optional |
A session opened with a proof is bound. Sessions without one still work, so older SDKs and browsers without WebCrypto keep working. |
required |
Sign-in and sign-up without a proof are refused (400 device_key_required). Sessions that aren't bound stop working: switching to required signs out everyone who hasn't got a key, including sessions that exist now. |
A bound session stays bound even if you later switch to off.
How it works
- The SDK makes an ECDSA P-256 key with
extractable: falseand keeps it in IndexedDB. Script on the page can use the key to sign but can never read it. - On every request to the frontend API it adds a
DPoPheader: a JWT signed by that key naming the HTTP method and URL, with a randomjtiand the time. - When a session is created, the thumbprint of the key (RFC 7638) is stored with it. From then on every use of the cookie (token refresh, reading the session, account changes, switching organization) must carry a proof from the same key. A missing or different key gives
401 device_proof_requiredor401 invalid_dpop_proof. - The session token (JWT) of a bound session carries
cnf: { "jkt": "…" }.verifyTokenreturns it asdeviceKey.
The service accepts only ES256 with a public P-256 key in the header, typ: dpop+jwt, htm and htu matching the request (the URL without query), iat within 60 seconds, and a jti it has not seen in the last two minutes (kept in Valkey). There is no server-issued nonce; replay is bounded by iat and jti.
Signing out needs only the cookie, so a browser that lost its key can still end the session.
Sign-ins that return through a redirect
Social sign-in, SSO and the link in an email come back as a browser redirect, which cannot carry a proof. Those sessions start unbound; in required mode they may do nothing except be bound for the first two minutes. The SDK's load() sees that the session is unbound and calls:
POST /v1/client/session/bind (with a DPoP header)
→ { "sessionId": "…", "deviceBound": true }It works once per session, only within two minutes of sign-in, and never replaces an existing key (409 already_bound, 403 bind_window_closed). Until then the cookie is unbound, so this window is the one place where a cookie stolen in the first seconds could win the race. Everything after it is bound.
In the SDK
const client = createAuthClient({ publishableKey, frontendApi }); // nothing else to configureThe key is made the first time a session is about to open and the app asks for binding. createAuthClient takes deviceKeyStore to keep keys somewhere other than IndexedDB (tests, non-browser runtimes). createDpopProof, generateDeviceKey and deviceKeysSupported are exported from @daywalker/auth.
authProxy's refresh page uses the same key, so server-rendered pages keep refreshing.
GET /v1/client/session returns deviceBound, deviceBindable and deviceBinding; the environment endpoint returns sessions.deviceBinding.
Requirements and limits
frontendApimust be the service's own origin (APP_URL): the proof names the URL it was sent to.- Clearing site data deletes the key. The user is signed out and signs in again.
- Platform support impersonation sessions are not bound.
- The key stops a stolen cookie. Malicious script running in your own page can still ask the browser to sign requests while the page is open; keep your CSP strict.
Audit events: session.device_bound, instance.sessions_updated (with deviceBinding); user.signed_in records deviceBound.