Back

Security

Keybound

Steal someone's session cookie and you are them. Every app, every permission, every piece of data they have access to. I built Keybound to stop the most common way that happens, copied cookies from infostealer logs and disk dumps, by binding browser sessions to a device key that cannot leave the browser. TypeScript, zero dependencies, works with any Node.js framework.

Session cookies are the weakest link in most web apps. Someone dumps a Chrome profile, grabs a backup, catches an infostealer log. Now they have a valid session token. They drop it into their own browser and the server sees a legitimate logged-in user. MFA already happened. IP checks mean nothing against residential proxies. The token is all it takes.

Keybound adds one check. Before a sensitive action, the server challenges the browser to prove it holds a private key that was created during login. The key lives in IndexedDB as a Web Crypto nonextractable key. The browser can sign with it, but cannot export the raw bytes. Stolen cookies, different machine, no key. The proof fails, the action is denied.

How it works

Enrollment

Browser creates a P-256 key pair. Private key stays in IndexedDB as a CryptoKey flagged nonextractable. Public key goes to the server, linked to the session and a device ID.

Challenge and proof

User hits a protected action. Server issues a challenge tied to that session, that device, and that purpose. Browser signs it with the enrolled private key and returns the signature.

Verification

Server loads the enrolled public key from its own database, verifies the HMAC and the P-256 signature, and checks that the challenge has not been consumed yet. The consume step is atomic. One conditional delete, one winner. Challenge reuse is blocked at the storage layer, not in application logic.

What it protects against

Cookie replay from a different machine. Before and after:

attack without Keybound
1. User logs in
2. Browser stores session cookie
3. Attacker copies cookie from disk dump or infostealer log
4. Attacker sends the cookie from another machine
5. Server sees a valid session token -- lets them in
protected with Keybound
1. User logs in
2. Browser creates nonextractable P-256 key
3. Server stores the matching public key
4. Attacker copies the cookies
5. Server asks for a fresh signed challenge
6. Attacker has cookies but not the browser key -- blocked

The limits

Keybound does not stop an attacker who can run JavaScript inside the real browser. XSS, malicious extensions, and malware on the live machine can call signChallenge because the real key is present. This is true of any browser-based proof system and there is no way around it.

It also does not stop a compromised server, a stolen server secret, or a database read by an attacker. Those are separate threat models with their own mitigations.

What Keybound does is raise the floor for the most common attack: stolen cookies. Right now that is a copy-paste operation. With Keybound, it requires live code execution inside the victim's browser. For infostealer logs, profile dumps, and leaked backups, the attack stops cold.

Install

TypeScript, ESM, no runtime dependencies.

shell
npm install keybound

Node.js 20+. Works with Express, Fastify, Next.js, Hono, and plain Node.

Usage

Server side:

typescript
import { createKeybound } from "keybound";

export const keybound = createKeybound({
  secret: process.env.KEYBOUND_SECRET,
  preset: "default"
});

// Issue a challenge
const issued = keybound.issueChallenge({
  sessionId,
  deviceId,
  publicKey,
  purpose: "session:renew"
});

// Verify the proof
const result = await keybound.verifyAndConsumeProof({
  store: challengeStore,
  sessionId,
  deviceId,
  publicKey,
  purpose: "session:renew",
  challengeId,
  challenge,
  signature
});

if (!result.ok) {
  // Deny or step up
}

Browser side:

javascript
import { getOrCreateKeyboundBrowserKey } from "keybound/browser";

const deviceKey = await getOrCreateKeyboundBrowserKey();

// Enroll with your server
await fetch("/keybound/enroll", {
  method: "POST",
  body: JSON.stringify({ publicKey: deviceKey.publicKey })
});

// Sign a challenge
const signature = await deviceKey.signChallenge(issued.challenge);

MIT license. On npm and GitHub.

github.com/chrisch88dev/keybound

npmjs.com/package/keybound