tether-sessions
v0.1.0
Published
Device-bound sessions: a stolen session token is useless without the device's non-extractable key. DPoP-style proof-of-possession as a drop-in middleware for any backend.
Maintainers
Readme
Tether — device-bound sessions
A stolen session token is useless without the device's key.
A bearer token is "whoever holds it, wins" — so stealing the cookie (infostealer, XSS, leaked log, MITM) hands the attacker your session, straight past MFA. Tether binds each token to a non-extractable device key. Every request carries a fresh proof-of-possession (PoP) signed by that key. Steal the token and, without the private key that never left the real device, it's dead on any other machine.
This is the DPoP (RFC 9449) family. Google is shipping the close cousin DBSC in Chrome. Tether's niche: a drop-in for stacks outside Google — ~5 lines on the client, one middleware on the server, any backend, self-hosted.
Status: working prototype. Core, browser client, Express middleware, Redis store, multi-device + rotation, and a nonce challenge — all tested. Zero runtime dependencies. Not yet published to npm.
Integration in ~5 lines
Browser (client) — the key is generated extractable:false and lives in
IndexedDB; JS can ask it to sign but can never read it out:
import { Device } from 'tether-sessions/client';
const device = await Device.loadOrCreate();
await fetch('/login', { method: 'POST', body: JSON.stringify({ publicJwk: device.publicJwk }) });
// on every request:
const proof = await device.createProof({ method: 'GET', url, token });
fetch(url, { headers: { Authorization: `Bearer ${token}`, PoP: proof } });Server (Express):
const tether = require('tether-sessions/express')();
app.post('/login', async (req, res) => {
// ...authenticate the user your own way (password, OAuth)...
const token = await tether.issueSession(res, req.body.publicJwk); // binds token to key
res.json({ token });
});
app.get('/me', tether.protect(), (req, res) => res.json({ ok: true })); // now proof-gatedThat's it. Every request to a protect()ed route now needs a valid, fresh proof.
What verify() catches
| # | Attack | Rejected by |
|---|--------|-------------|
| 1 | Token replayed with no proof | missing proof-of-possession |
| 2 | Proof signed with the attacker's own key | thumbprint mismatch (RFC 7638) |
| 3 | A captured proof replayed verbatim | jti replay cache |
| 4 | A /me proof reused against /transfer | htm / htu binding |
| 5 | A proof minted for a different token | ath (token hash) binding |
| 6 | An expired / pre-stamped proof | iat window + optional server nonce |
Every one of these still assumes the token was already stolen — and every time it's useless without the private key.
Pieces
| Module | What |
|--------|------|
| tether-sessions (index) | Device, Server, MemoryStore, RedisStore, Accounts, express |
| tether-sessions/client | Browser WebCrypto client (ES module), non-extractable key in IndexedDB |
| tether-sessions/express | protect() middleware + issueSession() login helper |
| tether-sessions/store | MemoryStore (default, TTL) and RedisStore (multi-node, atomic replay via SET NX EX) |
| tether-sessions/accounts | Multiple devices per user + key rotation |
| browser-demo.html | Self-contained demo: runs all six steal scenarios in your browser |
Multi-node
const { Server, RedisStore } = require('tether-sessions');
const server = new Server({ store: new RedisStore(redisClient), nonceSecret: process.env.TETHER_SECRET });Token bindings get the session TTL; the replay cache uses SET NX EX (atomic, no
cross-process race). Share nonceSecret across nodes so any node can validate a
nonce another issued.
Multiple devices & key rotation
const { Accounts } = require('tether-sessions');
const accounts = new Accounts(server);
await accounts.addDevice('user:42', laptopJwk);
await accounts.addDevice('user:42', phoneJwk);
const t2 = await accounts.rotateKey('user:42', oldToken, newLaptopJwk);
await accounts.revokeAll('user:42'); // log out everywhereOptional server nonce (anti pre-stamping)
const server = new Server({ requireNonce: true });On a nonce-less proof the middleware answers 401 with a Tether-Nonce header; the
client re-signs including that nonce and retries. Nonces are stateless (HMAC over a
timestamp), reusable within their TTL, and unforgeable without the secret.
Performance
The whole pitch depends on the per-request cost being negligible. On Node v24
(Ed25519, MemoryStore):
p50 p99 (microseconds)
createProof 45 143
verify 185 308verify p99 ≈ 0.31 ms — well under the 1 ms target — ≈5,000 verifies/sec/core.
Run it yourself: npm run bench.
Algorithm
- Browser: ECDSA P-256 (ES256). Universal WebCrypto support across every non-Chrome / self-hosted target — the whole point of Tether. (Ed25519 in WebCrypto only landed recently and unevenly.)
- Native / Node devices: Ed25519 (EdDSA).
- The server accepts both, and pins the algorithm to the key type, so a proof can't claim ES256 over an Ed25519 key.
Try it live (end-to-end)
A real HTTP server (server.js, zero deps) protecting /me and /transfer, with
requireNonce:true, serving a browser client from the same origin:
npm start # -> http://localhost:8787Open it and hit Run full end-to-end flow. You'll watch, over real fetch:
POST /login -> token bound to the device key
GET /me (no nonce) -> 401 missing nonce + Tether-Nonce header
GET /me (nonce) -> 200 (client auto-retried)
GET /me (no proof) -> 401 missing proof-of-possession [stolen token alone]
GET /me (wrong key) -> 401 proof key != bound device key [attacker's key]Run the tests
npm test # demo + express + store + advanced + live server (63 checks)
npm run bench # per-request overhead
# standalone browser demo (no server): open browser-demo.html via any static host
python -m http.server 8722 # then open http://localhost:8722/browser-demo.htmlRequires Node 18+ (Ed25519 in crypto). Verified on Node v22 and v24.
License
MIT
