Skip to content

About

Device Bound Session Credentials (DBSC) for Node.js — middleware that binds session cookies to a hardware TPM key to stop cookie theft, with a Web Crypto polyfill for Firefox & Safari.

Topics

Resources

Contributing

Security policy

Stars

12 stars

Watchers

0 watching

Forks

Repository files navigation

DBSC Toolkit

DBSC Toolkit

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.

npm downloads CI TypeScript License Node

The problem

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.

The solution

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  ✋

Login → bound → stolen-cookie replay → 403

What a stolen cookie gets the attacker

Attacker replays the stolen cookie from another machine and the request is rejected

The cookie copies fine. The key doesn't — it never left the user's device. So the replayed request fails the proof check.

Live demo

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.

Why DBSC Toolkit exists

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.

Install

npm install dbsc-toolkit

Framework 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 + Postgres

Integration in 3 lines

Drop 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 route

Then guard sensitive routes with requireProof(). The full picture:

Quick start

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.

Any framework

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.

How it compares

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.

Security at a glance

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 the dbsc tier (TPM) defeats this; the bound polyfill key is on disk
  • ⚠️ Browser/OS compromise, rogue extension with subtle.sign access

Full STRIDE analysis: docs/security/threat-model.md · best practices: docs/security/best-practices.md.

Protection tiers

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 (TPM-backed), bound (Web Crypto polyfill), and none

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.

Optional: DPoP for bearer tokens (RFC 9449)

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.

Ecosystem

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()]

Protocol & spec

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.

Roadmap

  • 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:http adapters
  • DPoP (RFC 9449) for bearer-token binding
  • Bun / Deno native paths
  • Third-party security audit

Subpath imports

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

Docs

Status

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.

License

Apache 2.0

About

Device Bound Session Credentials (DBSC) for Node.js — middleware that binds session cookies to a hardware TPM key to stop cookie theft, with a Web Crypto polyfill for Firefox & Safari.

Topics

Resources

Contributing

Security policy

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages