Stop stolen session cookies from being replayed on another device.
Device Bound Session Credentials for Node.js — one framework-agnostic core with adapters for Express, Fastify, Hono, Next.js, NestJS, Koa, SvelteKit, Electron and raw node:http, plus a Web Crypto polyfill so every browser is covered, not just Chrome. A language-neutral protocol spec with a Node reference implementation.
An attacker steals a session cookie — XSS, infostealer malware, a leaked log, a compromised proxy. The moment they have it, they paste it into their own browser and they're you. HttpOnly didn't matter. Secure didn't matter. Rotating the token only shortens the window; it doesn't close it.
DBSC binds the session to a cryptographic key generated on the user's device at login. The cookie is still stealable — but every refresh, and every guarded request, needs a signature from that key. An attacker on another machine has nothing to sign with. The replay 403s.
Browser Server
─────── ──────
sign in ─────────────────────────────▶ issue session + Secure-Session-Registration
generate keypair (TPM / IndexedDB)
register public key ──────────────────▶ store key, bind to session
◀────────────────── bound session cookie
...later, on a guarded request:
sign challenge with private key ──────▶ verify signature → 200
stolen cookie, no key ──────▶ 403 ✋
The cookie copies fine. The key doesn't — it never left the user's device. So the replayed request fails the proof check.
https://dbsc-toolkit.onrender.com/ — sign in, then hit the "simulate stolen cookie" button. It fires a bare request with the bound cookie and no proof, and comes back 403 PROOF_MISSING. That's the whole library in one button.
Chromium 145+ lands on tier: "dbsc" (TPM-backed); Firefox/Safari land on tier: "bound" (Web Crypto polyfill). Same guard either way.
Native DBSC currently ships only on Chromium-based browsers. That leaves Firefox, Safari, mobile, and older Chromium users on plain cookies — defeating the point if you're trying to stop cookie theft across your whole user base.
DBSC Toolkit extends device-bound sessions to those browsers through a Web Crypto polyfill, so every user gets the same per-request guard. Today it's one of the few open-source implementations providing cross-browser DBSC-style protection for Node.js.
It's built in two layers. The protocol — every header, JWS shape, and the storage and cookie contracts — is written up as a language-neutral spec in spec/, with real test vectors. This Node.js package is the reference implementation of that spec. The spec is designed so a conforming server can be built in any ecosystem, and the test vectors are what each one targets.
PHP implementation: report-uri/dbsc-php — an independent native-only DBSC server by Scott Helme (Report URI). It was built separately and cross-validates against these same vectors, so the two implementations verify each other's ES256 proofs.
npm install dbsc-toolkitFramework and storage drivers are optional peer deps — install only what you use:
npm install express ioredis # Express + Redis
npm install fastify @fastify/cookie pg # Fastify + PostgresDrop into an existing Express app — no rewrite of your login or session store:
const dbsc = createDbsc({ storage }); // 1. configure once
dbsc.install(app); // 2. mount protocol routes + SDK
await dbsc.bind(res, sessionId, { userId }); // 3. bind, in your login routeThen guard sensitive routes with requireProof(). The full picture:
import express from "express";
import { randomUUID } from "node:crypto";
import { createDbsc } from "dbsc-toolkit/express";
import { MemoryStorage } from "dbsc-toolkit/storage/memory";
const app = express();
app.use(express.json());
const dbsc = createDbsc({ storage: new MemoryStorage() }); // Redis/Postgres in prod
dbsc.install(app); // mounts protocol routes + SDK
app.post("/login", async (req, res) => {
await dbsc.bind(res, randomUUID(), { userId: req.body.username }); // the one new line
res.json({ ok: true });
});
app.post("/payment", express.raw({ type: "*/*" }), dbsc.requireProof(), payHandler);Load the polyfill in your HTML so non-Chromium browsers reach tier: "bound":
<script type="module">
import { initBoundDbsc } from "/dbsc-client/index.js";
initBoundDbsc();
</script>Full walk-through, failure modes, and migration timeline: docs/quickstart.md and docs/guide.md.
The protocol lives in a framework-agnostic core — plain functions plus a StorageAdapter, no HTTP-layer assumptions. The adapters are thin wrappers; pick yours:
| Framework | Import |
|---|---|
| Express | dbsc-toolkit/express |
| Fastify | dbsc-toolkit/fastify |
| Hono (Node, Bun, Deno, Workers) | dbsc-toolkit/hono |
| Next.js (App Router) | dbsc-toolkit/nextjs |
| NestJS | dbsc-toolkit/nestjs |
| Koa | dbsc-toolkit/koa |
| SvelteKit | dbsc-toolkit/sveltekit |
| Electron (main process) | dbsc-toolkit/electron |
Any other server (raw node:http) |
dbsc-toolkit/node |
| Better Auth | @dbsc-toolkit/better-auth |
No adapter for your framework? You can still use it. Every adapter is a thin shell over the same core, so any HTTP framework on Node works — wire the core in directly:
import {
handleRegistration, handleRefresh,
handleBoundRegistration, handleBoundRefresh,
verifyBoundProof, // your requireProof() equivalent
issueChallenge, buildRegistrationHeader,
type StorageAdapter, // the only interface you implement
} from "dbsc-toolkit";The generic dbsc-toolkit/node adapter is exactly this over raw node:http. Complete example: docs/adapters.md.
| Plain cookies | JWT (bearer) | Native DBSC (Chrome 145+) | dbsc-toolkit | |
|---|---|---|---|---|
| Replay-resistant (stolen cookie from another device) | ❌ | ❌ | ✅ | ✅ |
| Works on Chrome / Edge / Brave | ✅ | ✅ | ✅ (TPM) | ✅ |
| Works on Firefox / Safari / mobile / no-TPM | ✅ | ✅ | ❌ | ✅ (polyfill) |
| Per-request body-hash proof vs MITM | ❌ | ❌ | ❌ | ✅ |
| Captured-proof replay defense | n/a | ❌ | n/a | ✅ (replay cache) |
| Bearer/access-token replay (DPoP, RFC 9449) | n/a | ❌ | n/a | ✅ (dbsc-toolkit/dpop) |
| Multi-subdomain binding | loose | loose | ❌ | ✅ (cookieScope: "site") |
| Better Auth integration | n/a | n/a | n/a | ✅ (plugin) |
DBSC complements your existing auth (passwords, MFA, sessions, JWTs) — it closes replay after issue, the gap none of them cover. More: docs/security/threat-model.md.
Defended:
- ✅ Cookie theft replayed from another device
- ✅ Stolen bearer tokens (same category)
- ✅ XSS reading
document.cookie - ✅ Network capture / TLS-stripping proxy
- ✅ Server log leakage
- ✅ MITM body substitution (signed body hash)
Not defended (be honest about the boundary):
⚠️ On-device malware reading the browser profile — only thedbsctier (TPM) defeats this; theboundpolyfill key is on disk⚠️ Browser/OS compromise, rogue extension withsubtle.signaccess
Full STRIDE analysis: docs/security/threat-model.md · best practices: docs/security/best-practices.md.
Every session carries a tier. You don't gate on it directly — requireProof() does the enforcement — but it tells you how the binding was achieved on a given browser.
dbsc is hardware-backed (TPM / Secure Enclave). bound is the Web Crypto polyfill (non-extractable IndexedDB key). none is an unbound or stale session. Detail: HOW-IT-WORKS.md.
The polyfill is on by default. To run native DBSC only (Chromium 145+, no bound tier), pass bound: false — the /dbsc-bound/* routes don't mount and requireProof() relaxes to the native binding.
DBSC binds a session cookie. If you hand out bearer / access tokens for an API, those have the same replay problem a cookie does — copy the Authorization header, use it anywhere. DPoP closes that the same way: bind the token to a device key and require a per-request DPoP proof. It ships as a separate import, off the default path, so projects that don't use it pay nothing.
import { dpopConfirmation } from "dbsc-toolkit/dpop";
import { requireDpop } from "dbsc-toolkit/express";
// at issue time — bind the token to the device key:
const { jkt } = await dpopConfirmation(deviceJwk);
const token = signAccessToken({ sub, cnf: { jkt } }); // your JWT lib
// on the resource route — verify the proof and the binding:
app.get("/api/resource", requireDpop({ getBoundJkt }), handler);requireDpop is exported from every adapter (Express, Fastify, Hono, Next.js, NestJS, Koa, SvelteKit, Electron, node:http). A failed check answers 401 with WWW-Authenticate: DPoP. Always pass getBoundJkt so the presented token is bound to its key — see docs/dpop.md and spec/10-dpop.md.
| Package | What it is |
|---|---|
dbsc-toolkit |
Core + adapters for Express, Fastify, Hono, Next.js, NestJS, Koa, SvelteKit, Electron & raw node:http; memory/Redis/Postgres storage |
dbsc-toolkit/dpop |
Optional DPoP (RFC 9449) — bind bearer/access tokens to a device key |
dbsc-toolkit/client |
Browser SDK + Web Crypto polyfill |
@dbsc-toolkit/better-auth |
First-class Better Auth plugin — binds every sign-in method automatically |
// Better Auth — one plugin line, works on every framework:
import { dbsc } from "@dbsc-toolkit/better-auth" // auth.ts → plugins: [dbsc()]The wire protocol is documented as a language-neutral spec in spec/: header formats, JWS and JSON shapes, the storage and cookie contracts, error codes, and real test vectors. dbsc-toolkit is the reference implementation — the spec is what lets a conforming DBSC server be built in any language, not just Node.
spec/README.md— start herespec/02-native-protocol.md— the native Chromium flow on the wirespec/09-conformance.md— what "conforming" means and how to verify it
- Express, Fastify, Hono, Next.js adapters
- Memory, Redis, PostgreSQL storage
- Web Crypto polyfill (Firefox / Safari / mobile / older Chromium)
- Per-request proof + body signing + replay cache
- Multi-subdomain binding (
cookieScope: "site") - Better Auth plugin
- NestJS, Koa, SvelteKit, Electron & generic
node:httpadapters - DPoP (RFC 9449) for bearer-token binding
- Bun / Deno native paths
- Third-party security audit
| Import | What it is |
|---|---|
dbsc-toolkit |
Core: types, crypto, protocol handlers, framework-agnostic |
dbsc-toolkit/express · /fastify · /hono · /nextjs · /nestjs · /koa · /sveltekit · /electron · /node |
Framework adapters |
dbsc-toolkit/dpop |
Optional DPoP (RFC 9449) verifier + requireDpop per adapter |
dbsc-toolkit/client |
Browser SDK + polyfill |
dbsc-toolkit/storage/{memory,redis,postgres} |
Storage adapters |
- Protocol spec (language-neutral): spec/ · conformance · test vectors
- Other languages — PHP: report-uri/dbsc-php (independent implementation, cross-validated against these vectors)
- Concepts & protocol: HOW-IT-WORKS.md
- Getting started: docs/quickstart.md
- Add to an existing app: docs/guide.md
- Per-request signing & replay cache: docs/request-signing.md
- Bound polyfill wire protocol: docs/polyfill.md
- API reference: docs/api-reference.md
- Adapters (+ build your own): docs/adapters.md
- Storage: docs/storage.md · Deployment: docs/deployment.md
- Security: threat model · best practices
- Troubleshooting: docs/troubleshooting.md
Verified end-to-end on Chrome 147 / Windows / TPM 2.0. Native DBSC rolls out on Windows from Chrome 145 (TPM) and on macOS from Chrome 147 (Secure Enclave), both as gradual rollouts — a recent enough Chrome on supported hardware reaches tier: "dbsc" on either OS. The polyfill covers every other browser with Web Crypto + IndexedDB. No third-party security audit yet — see HOW-IT-WORKS.md#production-readiness.
Apache 2.0



