https://github.com/paveg/hono-dpop
DPoP (RFC 9449) middleware for Hono — server-side validator for DPoP-bound access tokens.
https://github.com/paveg/hono-dpop
cloudflare-workers dpop hono jwt middleware oauth2 rfc9449 typescript
Last synced: about 2 months ago
JSON representation
DPoP (RFC 9449) middleware for Hono — server-side validator for DPoP-bound access tokens.
- Host: GitHub
- URL: https://github.com/paveg/hono-dpop
- Owner: paveg
- License: mit
- Created: 2026-04-27T18:51:49.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-05-08T13:43:37.000Z (about 2 months ago)
- Last Synced: 2026-05-08T15:41:11.559Z (about 2 months ago)
- Topics: cloudflare-workers, dpop, hono, jwt, middleware, oauth2, rfc9449, typescript
- Language: TypeScript
- Size: 279 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- Funding: .github/FUNDING.yml
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
Awesome Lists containing this project
README
# hono-dpop
[](https://www.npmjs.com/package/hono-dpop)
[](https://github.com/paveg/hono-dpop/actions/workflows/ci.yml)
[](https://codecov.io/gh/paveg/hono-dpop)
[](https://opensource.org/licenses/MIT)
DPoP ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) proof-of-possession middleware for [Hono](https://hono.dev).
Validates the `DPoP` header on incoming requests: signature, `htm`/`htu`/`iat`, `jti` replay protection, and (optional) `ath` binding to a bearer access token. Pluggable replay-cache store. RFC 9457 Problem Details errors with RFC 9449 `WWW-Authenticate` semantics.
## Features
- Verifies the DPoP proof JWT (signature against the embedded JWK)
- Validates `htm` / `htu` / `iat` per RFC 9449 §4.3
- `jti` replay protection via pluggable store
- Optional `Authorization: DPoP ` extraction with `ath` claim verification
- Exposes `c.get("dpop")` containing `{ jkt, jti, jwk, htm, htu, iat, ath?, raw }`
- RFC 9457 Problem Details error responses with RFC 9449 `WWW-Authenticate: DPoP error="..."`
- Algorithms: ES256/384/512, RS256/384/512, PS256/384/512, EdDSA. `none` and `HS*` rejected.
- Web Crypto only — works on Node.js ≥20, Cloudflare Workers, Deno, Bun
## Install
```bash
npm install hono-dpop
pnpm add hono-dpop
yarn add hono-dpop
bun add hono-dpop
```
Works on Cloudflare Workers, Deno, and Bun in addition to Node.js. Pure Web Crypto — no native bindings, no platform-specific dependencies, runs unchanged on x64 / arm64 / Linux / macOS / Windows.
### Requirements
- Hono `>= 4.0.0` (peer dependency)
- TypeScript `>= 5.0` — the published `.d.ts` files are CI-tested against TS 5.0, 5.4, 5.7, and 5.9. Older TS versions may work but are not verified.
- Node.js `>= 22` (for Node.js consumers)
## Quick Start
```ts
import { Hono } from "hono";
import { dpop } from "hono-dpop";
import { memoryNonceStore } from "hono-dpop/stores/memory";
const app = new Hono();
app.use("/api/*", dpop({ nonceStore: memoryNonceStore() }));
app.get("/api/me", (c) => {
const proof = c.get("dpop");
// proof.jkt is the SHA-256 JWK thumbprint of the client's public key.
// Compare against your access token's `cnf.jkt` claim to enforce binding.
return c.json({ subject: "user-123", boundKeyThumbprint: proof?.jkt });
});
```
## Options
```ts
dpop({
// Required: replay cache for jti
nonceStore: memoryNonceStore(),
// Allowed algorithms (default: all supported asymmetric)
algorithms: ["ES256", "ES384", "EdDSA"],
// Max clock skew on iat in seconds (default: 60)
iatTolerance: 60,
// How long a jti is remembered in milliseconds (default: 5 minutes)
jtiTtl: 5 * 60_000,
// Override request URL extraction for reverse-proxy scenarios
// Default: c.req.url, with query/fragment stripped
getRequestUrl: (c) => `https://api.example.com${c.req.path}`,
// Override access-token extraction
// Default: parses `Authorization: DPoP `
getAccessToken: (c) => c.req.header("X-Access-Token"),
// Reject when access token is missing (default: false)
requireAccessToken: true,
// Custom error response (default: RFC 9457 Problem Details)
onError: (error, c) => c.json({ error: error.code }, error.status),
// Server-issued nonce challenge (RFC 9449 §8). When set, proofs missing or
// with an invalid `nonce` claim are rejected with `error="use_dpop_nonce"`
// and a fresh nonce in the `DPoP-Nonce` response header. Successful
// responses also echo the current nonce.
nonceProvider: memoryNonceProvider({ rotateAfter: 5 * 60_000 }),
// DoS shield — reject inputs above these sizes before any decode work.
maxProofSize: 8192, // bytes (default: 8192)
maxAccessTokenSize: 4096, // bytes (default: 4096)
// Inject a clock for tests / clock-skew compensation. Returns ms epoch.
clock: () => Date.now(),
// htu comparison policy. "strict" (default) requires exact equality after
// URL normalization. "trailing-slash-insensitive" strips a trailing `/`
// from non-root paths before comparison.
htuComparison: "strict",
// Allow proofs whose `iat` is in the future (default: false). When true,
// only past staleness is rejected: `iat < now - iatTolerance`.
allowFutureIat: false,
});
```
### Helpers
```ts
import { assertJktBinding, verifyJktBinding } from "hono-dpop";
// Throwing variant — pipes through the standard 401 + WWW-Authenticate path:
app.get("/api/me", (c) => {
const proof = c.get("dpop")!;
const claims = await verifyMyAccessToken(c.req.header("Authorization"));
assertJktBinding(claims, proof.jkt); // throws DPoPProofError on mismatch
return c.json({ ok: true });
});
// Boolean variant for explicit branching:
if (!verifyJktBinding(claims, proof.jkt)) {
return c.text("binding failed", 401);
}
```
## Errors
All errors follow [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457) and include the [RFC 9449 §7.1](https://www.rfc-editor.org/rfc/rfc9449#section-7.1) `WWW-Authenticate: DPoP error="..."` header.
| Status | Code | `error=` | When |
|--------|------|----------|------|
| 401 | `INVALID_DPOP_PROOF` | `invalid_dpop_proof` | Header missing/malformed, signature invalid, claims invalid (htm, htu, iat, typ, alg, jwk), oversized proof or token, multiple `DPoP` headers |
| 401 | `MISSING_ACCESS_TOKEN` | `invalid_token` | `requireAccessToken: true` and `Authorization: DPoP` missing |
| 401 | `ATH_MISMATCH` | `invalid_token` | `ath` claim does not match SHA-256 of access token |
| 401 | `JTI_REPLAY` | `invalid_dpop_proof` | `jti` already used within `jtiTtl` window |
| 401 | `USE_NONCE` | `use_dpop_nonce` | `nonceProvider` is set and proof has no current `nonce` claim. Response carries a fresh `DPoP-Nonce` header and `nonce="..."` parameter on `WWW-Authenticate`. |
Every 401 also carries an `algs=""` parameter on `WWW-Authenticate` so clients can discover supported algorithms without trial-and-error (RFC 9449 §7.1).
When [hono-problem-details](https://github.com/paveg/hono-problem-details) is installed, error responses are generated using its `problemDetails().getResponse()`. Otherwise, a built-in fallback is used. No configuration needed — detection is automatic.
## Stores
The replay-cache `nonceStore` enforces RFC 9449 §11.1 — each `jti` is accepted at most once within its freshness window. Pick the backend that matches your runtime and consistency needs.
### Choosing a Store
| Store | Consistency | Durability | Atomic insert-if-absent | Native TTL | Setup | Best for |
|-------|-------------|------------|-------------------------|------------|-------|----------|
| `memory` | strong (per process) | none (in-RAM) | yes (JS single thread) | manual sweep | none | dev, tests, single-instance |
| `redis` | strong | yes | yes (`SET NX EX`) | yes (`EX`) | provision Redis client | multi-instance servers, Workers (Upstash) |
| `cloudflare-kv` | eventual | yes | best-effort | yes | bind a KV namespace | Workers when DO/D1 are overkill; tolerates rare replays |
| `cloudflare-d1` | strong (single primary) | yes | yes (`INSERT OR IGNORE`) | manual `purge()` | bind a D1 database | Workers wanting strict atomicity without a DO |
| `durable-objects` | strong (single writer) | yes | yes (single-writer) | manual `purge()` | DO class storage | Workers needing per-tenant isolation + strict consistency |
### Memory Store
Built-in, suitable for single-instance deployments and development.
```ts
import { memoryNonceStore } from "hono-dpop/stores/memory";
const nonceStore = memoryNonceStore({
ttl: 5 * 60_000, // milliseconds (default: 5 minutes)
maxSize: 10_000, // optional FIFO bound
});
```
### Redis Store
Bring your own client (ioredis, node-redis, or @upstash/redis). Uses `SET key 1 NX EX ` for an atomic insert-if-absent in a single round-trip.
```ts
import Redis from "ioredis";
import { redisStore } from "hono-dpop/stores/redis";
const nonceStore = redisStore({
client: new Redis(process.env.REDIS_URL!),
ttl: 300, // seconds, default 300
keyPrefix: "dpop:jti:",
});
```
### Cloudflare KV Store
Works on Workers. KV is eventually consistent across edge POPs, so two requests can rarely both observe a jti as absent and both succeed — RFC 9449 explicitly tolerates best-effort enforcement here.
```ts
import { kvStore } from "hono-dpop/stores/cloudflare-kv";
// inside a Workers fetch handler with a KV binding `NONCE_KV`
const nonceStore = kvStore({ namespace: env.NONCE_KV });
```
### Cloudflare D1 Store
SQLite-backed strong consistency on Workers. Auto-creates the table on first use; call `purge()` from a scheduled handler to reclaim expired rows.
```ts
import { d1Store } from "hono-dpop/stores/cloudflare-d1";
// inside a Workers fetch handler with a D1 binding `DB`
const nonceStore = d1Store({ database: env.DB });
// optional: scheduled() { await nonceStore.purge(); }
```
### Durable Objects Store
Per-object single-writer guarantee → atomic without explicit locks. Ideal for per-tenant or per-key isolation.
```ts
import { durableObjectStore } from "hono-dpop/stores/durable-objects";
// inside a Durable Object class
const nonceStore = durableObjectStore({ storage: this.ctx.storage });
```
### Custom Store
```ts
import type { DPoPNonceStore } from "hono-dpop";
const customStore: DPoPNonceStore = {
// Atomically: returns true if jti was NOT seen, false if already seen.
async check(jti, expiresAt) { /* ... */ },
async purge() { /* return number of removed entries */ },
};
```
### Nonce Provider (RFC 9449 §8)
Optional. When set, the middleware emits `use_dpop_nonce` challenges and validates the `nonce` claim on subsequent proofs.
```ts
import { dpop, memoryNonceProvider } from "hono-dpop";
app.use("/api/*", dpop({
nonceStore: memoryNonceStore(),
nonceProvider: memoryNonceProvider({
rotateAfter: 5 * 60_000, // ms (default: 5 min)
retainPrevious: true, // accept the previous nonce too (default: true)
}),
}));
```
For multi-instance deployments, implement `NonceProvider` against a shared store:
```ts
import type { NonceProvider } from "hono-dpop";
const customProvider: NonceProvider = {
async issueNonce(c) { /* return a fresh nonce string */ },
async isValid(nonce, c) { /* return true for currently/recently valid nonces */ },
};
```
## Benchmarks
```bash
pnpm vitest bench
```
Representative numbers from a recent run (Apple M-series, Node 24):
```
parseProof ~370,000 ops/sec
jwkThumbprint ES256 ~130,000 ops/sec
verifyProofSignature RS256 ~ 22,000 ops/sec
verifyProofSignature ES256 ~ 12,000 ops/sec
verifyProofSignature ES512 ~ 1,100 ops/sec
memoryNonceStore.check (10k) ~1,800,000 ops/sec
```
The store is a Map lookup, so its throughput is independent of population. Verification cost is dominated by the curve / modulus.
## Accessing the Verified Proof in Handlers
```ts
import type { DPoPEnv } from "hono-dpop";
import { Hono } from "hono";
const app = new Hono();
app.get("/api/me", (c) => {
const proof = c.get("dpop");
if (!proof) return c.text("no proof", 401);
return c.json({
jkt: proof.jkt, // SHA-256 JWK thumbprint (RFC 7638), base64url
jti: proof.jti,
htm: proof.htm,
htu: proof.htu,
iat: proof.iat,
ath: proof.ath,
});
});
```
## What this middleware does NOT do
- It does **not** introspect or validate the access token. Use a separate middleware (e.g., your bearer/JWT verifier) to validate the access token, then call `assertJktBinding(claims, c.get("dpop")!.jkt)` to enforce DPoP binding.
- It does **not** verify multi-segment proxies. Use `getRequestUrl` to provide the canonical external URL when behind a reverse proxy.
## Documentation
- [ADR-0001: No jose dependency](./docs/adr/0001-no-jose-dependency.md)
- [ADR-0002: RFC 9457 Problem Details](./docs/adr/0002-rfc-9457-problem-details.md)
- [ADR-0003: 401 for all proof failures](./docs/adr/0003-401-for-all-proof-failures.md)
- [ADR-0004: jti replay via pluggable store](./docs/adr/0004-jti-replay-via-pluggable-store.md)
- [Threat model](./docs/security/threat-model.md)
## License
MIT