Session Device Binding
Tie a session to the device it was created on, so a stolen cookie does not work anywhere else.
A session cookie is a bearer token: whoever holds it is the user. If it is copied out of a browser, read from a log or taken over a shoulder, it works from the attacker's machine as well as the owner's. Binding records a fingerprint of the device at sign-in and checks it on the requests that follow, so a copy has to arrive from somewhere that looks like where it was issued.
The fingerprint is a hash of what the request reports about the client, with the address included only as a network prefix. A whole address would unbind a phone every time it changed cell, so a /24 or a /48 keeps the signal without punishing an ordinary day.
It raises the cost of using a stolen cookie rather than making one useless: an attacker on the same network, behind the same proxy, with the same browser build, still matches.
b.sessionDeviceBinding.fingerprint(req, opts?) #
{
ipPrefixBits: { v4: number, v6: number }, // default { v4: 24, v6: 48 } — mask width that survives a roaming IP
fingerprintExtras: function, // (req) => string|object, optional extra signal folded into the digest
}
Compute the stateless SHAKE256 device-shape digest for a request with no store, no session, and no side effects — the soft device-binding building block for self-validating tokens (a sealed cookie, a JWT) that carry the fingerprint inside the token and compare it themselves. create() requires a bindingStore for the persisted bind()/verify() lifecycle; this static form skips that gate because it touches no store. Returns a 32-byte Buffer derived from User-Agent + normalized Accept-Language / Accept-Encoding + the masked client-IP prefix (+ optional operator extras). Throws only on a missing or malformed request object.
var fp = b.sessionDeviceBinding.fingerprint(req);
// seal fp inside the cookie/JWT; on the next request recompute + constant-time compare
b.sessionDeviceBinding.create(opts?) #
{
session: object, // a b.session-shaped object
bindingStore: object, // where bindings are kept
storeInSession: boolean, // keep the binding in the session payload
ttlMs: number, // binding lifetime; default 604800000 (7 days)
ipPrefixBits: object, // { v4, v6 }; default { v4: 24, v6: 48 }
fingerprintExtras: function, // (req) → extra material to hash in
requireBoundKey: boolean, // also require the bound API key to match
boundKeyResolver: function, // (req) → the key id to bind against
clock: function, // () → epoch ms; a test seam
observability: object, // metrics sink
audit: boolean, // default true
}
Open a binder and answer the handle to work it. The handle carries bind(token, req), called once the session is established; verify(token, req), called on the requests that follow; unbind(token); and fingerprint(req).
bind records the fingerprint of the request that established the session. verify computes it again and answers { ok, reason }, where reason separates a fingerprint that does not match ("drift") from a session that was never bound ("missing-bind") and from a store that could not answer ("store-error"), so a caller can tell an attacker's cookie from a misconfiguration. A match also carries components, which is what went into the hash.
ipPrefixBits sets how much of the client address the fingerprint keeps: { v4, v6 }, defaulting to 24 and 48. Widening it unbinds a roaming client less often and narrows how much an attacker has to match; a whole address would unbind a phone whenever it changed cell.
bindingStore is where bindings live when they should not live in the session payload, which is what storeInSession: false selects. requireBoundKey with a boundKeyResolver ties the binding to an API key as well as the device. ttlMs is how long a binding stands before it has to be made again.
var b = require("@blamejs/core");
var binder = b.sessionDeviceBinding.create({ session: b.session });
await binder.bind(s.token, req); // at sign-in
await binder.verify(s.token, req); // on each later request