Device Binding (PoP)
Quick summary
Device binding uses a non-extractable key and a fresh proof on protected requests so a copied entitlement alone cannot authorize execution.
It does not prevent someone from using or cloning an already compromised browser environment. Follow the proof format only when implementing a compatible client.
Device binding ties an entitlement to a specific device keypair so a copied token alone is useless: every gated call re-proves possession of the private key whose public half is bound in license_bindings.
jkt — the binding identifier
jkt = sha256(sha256(RFC 7638 JWK thumbprint)) — a DevPay-specific double-hash (verified in experiments/secure-browser-licensing/tools/sdk-worker-contract.test.mjs), not the RFC 9449 thumbprint. The entitlement carries it twice: jkt_bound + cnf.jkt (same value; the SDK cross-checks when both are present).
The device keypair
- Browser (
@yetdapay/license-web): WebCrypto P-256 generatedextractable:false, persisted as aCryptoKeyin IndexedDB (createIndexedDbKeyStore). JS cannot export the private key — but abuse-in-place (hostile code callingsign()) and whole-profile clone remain possible; see Threat Model. - Slots: activation inserts
(lic_id, jkt)intolicense_bindings(UNIQUEconstraint);countBindings < max_devicesor403 DEVICE_LIMIT. Re-activation of the samejktis idempotent. max_devicesis per-license (seller_settings.default_max_devicesseeds new licenses). There is currently no unbind path —resetDevice()clears the local identity but burns the slot permanently (recorded gap).
The proof — DPoP: <proof-jws>
Per-request JWS signed by the device key, payload:
{
"htu": "/api/v1/licenses/execute?x=1", // same-origin pathname + query
"htm": "POST",
"iat": 1730000000,
"jti": "<nonce>",
"h": "base64url(sha256(raw request body))",
"jwk": { /* public jwk */ }
}2
3
4
5
6
7
8
Server checks (in guard order): proof signature → htm matches method → htu matches request target → iat within ±60 s → jti not seen before (atomic consume into license_nonces, TTL 600 s) → jwk's jkt equals the bound jkt_bound → h equals sha256(body).
Any failure → 401 UNAUTHORIZED — one opaque code for every check (deliberate; the specific reason lands in security_events, not the response).
What binding buys you
- Token theft is not enough — the thief needs the private key too.
- Replay is bounded —
jtinonces are single-use; staleiatrejected. - Body tampering fails —
hbinds the exact request bytes.
What it does not
- It does not prove the person — a paying user can relay authorized calls (metering/rate limits bound this).
- It does not survive a full profile+key clone — that is possession.
- It does not stop a seller from stripping checks in their own BYOS server (different trust domain).