{"id":49869859,"url":"https://github.com/paveg/hono-dpop","last_synced_at":"2026-05-15T05:31:53.022Z","repository":{"id":354553519,"uuid":"1222833746","full_name":"paveg/hono-dpop","owner":"paveg","description":"DPoP (RFC 9449) middleware for Hono — server-side validator for DPoP-bound access tokens.","archived":false,"fork":false,"pushed_at":"2026-05-08T13:43:37.000Z","size":286,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-08T15:41:11.559Z","etag":null,"topics":["cloudflare-workers","dpop","hono","jwt","middleware","oauth2","rfc9449","typescript"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/paveg.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":["paveg"]}},"created_at":"2026-04-27T18:51:49.000Z","updated_at":"2026-05-08T13:43:02.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/paveg/hono-dpop","commit_stats":null,"previous_names":["paveg/hono-dpop"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/paveg/hono-dpop","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paveg%2Fhono-dpop","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paveg%2Fhono-dpop/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paveg%2Fhono-dpop/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paveg%2Fhono-dpop/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/paveg","download_url":"https://codeload.github.com/paveg/hono-dpop/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paveg%2Fhono-dpop/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33055028,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-13T13:14:54.681Z","status":"online","status_checked_at":"2026-05-15T02:00:06.351Z","response_time":103,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cloudflare-workers","dpop","hono","jwt","middleware","oauth2","rfc9449","typescript"],"created_at":"2026-05-15T05:31:52.281Z","updated_at":"2026-05-15T05:31:53.015Z","avatar_url":"https://github.com/paveg.png","language":"TypeScript","funding_links":["https://github.com/sponsors/paveg"],"categories":[],"sub_categories":[],"readme":"# hono-dpop\n\n[![npm version](https://img.shields.io/npm/v/hono-dpop)](https://www.npmjs.com/package/hono-dpop)\n[![CI](https://github.com/paveg/hono-dpop/actions/workflows/ci.yml/badge.svg)](https://github.com/paveg/hono-dpop/actions/workflows/ci.yml)\n[![codecov](https://codecov.io/gh/paveg/hono-dpop/graph/badge.svg)](https://codecov.io/gh/paveg/hono-dpop)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\nDPoP ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) proof-of-possession middleware for [Hono](https://hono.dev).\n\nValidates 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.\n\n## Features\n\n- Verifies the DPoP proof JWT (signature against the embedded JWK)\n- Validates `htm` / `htu` / `iat` per RFC 9449 §4.3\n- `jti` replay protection via pluggable store\n- Optional `Authorization: DPoP \u003ctoken\u003e` extraction with `ath` claim verification\n- Exposes `c.get(\"dpop\")` containing `{ jkt, jti, jwk, htm, htu, iat, ath?, raw }`\n- RFC 9457 Problem Details error responses with RFC 9449 `WWW-Authenticate: DPoP error=\"...\"`\n- Algorithms: ES256/384/512, RS256/384/512, PS256/384/512, EdDSA. `none` and `HS*` rejected.\n- Web Crypto only — works on Node.js ≥20, Cloudflare Workers, Deno, Bun\n\n## Install\n\n```bash\nnpm  install hono-dpop\npnpm add     hono-dpop\nyarn add     hono-dpop\nbun  add     hono-dpop\n```\n\nWorks 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.\n\n### Requirements\n\n- Hono `\u003e= 4.0.0` (peer dependency)\n- TypeScript `\u003e= 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.\n- Node.js `\u003e= 22` (for Node.js consumers)\n\n## Quick Start\n\n```ts\nimport { Hono } from \"hono\";\nimport { dpop } from \"hono-dpop\";\nimport { memoryNonceStore } from \"hono-dpop/stores/memory\";\n\nconst app = new Hono();\n\napp.use(\"/api/*\", dpop({ nonceStore: memoryNonceStore() }));\n\napp.get(\"/api/me\", (c) =\u003e {\n  const proof = c.get(\"dpop\");\n  // proof.jkt is the SHA-256 JWK thumbprint of the client's public key.\n  // Compare against your access token's `cnf.jkt` claim to enforce binding.\n  return c.json({ subject: \"user-123\", boundKeyThumbprint: proof?.jkt });\n});\n```\n\n## Options\n\n```ts\ndpop({\n  // Required: replay cache for jti\n  nonceStore: memoryNonceStore(),\n\n  // Allowed algorithms (default: all supported asymmetric)\n  algorithms: [\"ES256\", \"ES384\", \"EdDSA\"],\n\n  // Max clock skew on iat in seconds (default: 60)\n  iatTolerance: 60,\n\n  // How long a jti is remembered in milliseconds (default: 5 minutes)\n  jtiTtl: 5 * 60_000,\n\n  // Override request URL extraction for reverse-proxy scenarios\n  // Default: c.req.url, with query/fragment stripped\n  getRequestUrl: (c) =\u003e `https://api.example.com${c.req.path}`,\n\n  // Override access-token extraction\n  // Default: parses `Authorization: DPoP \u003ctoken\u003e`\n  getAccessToken: (c) =\u003e c.req.header(\"X-Access-Token\"),\n\n  // Reject when access token is missing (default: false)\n  requireAccessToken: true,\n\n  // Custom error response (default: RFC 9457 Problem Details)\n  onError: (error, c) =\u003e c.json({ error: error.code }, error.status),\n\n  // Server-issued nonce challenge (RFC 9449 §8). When set, proofs missing or\n  // with an invalid `nonce` claim are rejected with `error=\"use_dpop_nonce\"`\n  // and a fresh nonce in the `DPoP-Nonce` response header. Successful\n  // responses also echo the current nonce.\n  nonceProvider: memoryNonceProvider({ rotateAfter: 5 * 60_000 }),\n\n  // DoS shield — reject inputs above these sizes before any decode work.\n  maxProofSize: 8192,        // bytes (default: 8192)\n  maxAccessTokenSize: 4096,  // bytes (default: 4096)\n\n  // Inject a clock for tests / clock-skew compensation. Returns ms epoch.\n  clock: () =\u003e Date.now(),\n\n  // htu comparison policy. \"strict\" (default) requires exact equality after\n  // URL normalization. \"trailing-slash-insensitive\" strips a trailing `/`\n  // from non-root paths before comparison.\n  htuComparison: \"strict\",\n\n  // Allow proofs whose `iat` is in the future (default: false). When true,\n  // only past staleness is rejected: `iat \u003c now - iatTolerance`.\n  allowFutureIat: false,\n});\n```\n\n### Helpers\n\n```ts\nimport { assertJktBinding, verifyJktBinding } from \"hono-dpop\";\n\n// Throwing variant — pipes through the standard 401 + WWW-Authenticate path:\napp.get(\"/api/me\", (c) =\u003e {\n  const proof = c.get(\"dpop\")!;\n  const claims = await verifyMyAccessToken(c.req.header(\"Authorization\"));\n  assertJktBinding(claims, proof.jkt); // throws DPoPProofError on mismatch\n  return c.json({ ok: true });\n});\n\n// Boolean variant for explicit branching:\nif (!verifyJktBinding(claims, proof.jkt)) {\n  return c.text(\"binding failed\", 401);\n}\n```\n\n## Errors\n\nAll 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.\n\n| Status | Code | `error=` | When |\n|--------|------|----------|------|\n| 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 |\n| 401 | `MISSING_ACCESS_TOKEN` | `invalid_token` | `requireAccessToken: true` and `Authorization: DPoP` missing |\n| 401 | `ATH_MISMATCH` | `invalid_token` | `ath` claim does not match SHA-256 of access token |\n| 401 | `JTI_REPLAY` | `invalid_dpop_proof` | `jti` already used within `jtiTtl` window |\n| 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`. |\n\nEvery 401 also carries an `algs=\"\u003cspace-separated\u003e\"` parameter on `WWW-Authenticate` so clients can discover supported algorithms without trial-and-error (RFC 9449 §7.1).\n\nWhen [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.\n\n## Stores\n\nThe 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.\n\n### Choosing a Store\n\n| Store | Consistency | Durability | Atomic insert-if-absent | Native TTL | Setup | Best for |\n|-------|-------------|------------|-------------------------|------------|-------|----------|\n| `memory`            | strong (per process) | none (in-RAM) | yes (JS single thread) | manual sweep   | none                    | dev, tests, single-instance |\n| `redis`             | strong               | yes           | yes (`SET NX EX`)      | yes (`EX`)     | provision Redis client  | multi-instance servers, Workers (Upstash) |\n| `cloudflare-kv`     | eventual             | yes           | best-effort            | yes            | bind a KV namespace     | Workers when DO/D1 are overkill; tolerates rare replays |\n| `cloudflare-d1`     | strong (single primary) | yes        | yes (`INSERT OR IGNORE`) | manual `purge()` | bind a D1 database    | Workers wanting strict atomicity without a DO |\n| `durable-objects`   | strong (single writer) | yes         | yes (single-writer)    | manual `purge()` | DO class storage       | Workers needing per-tenant isolation + strict consistency |\n\n### Memory Store\n\nBuilt-in, suitable for single-instance deployments and development.\n\n```ts\nimport { memoryNonceStore } from \"hono-dpop/stores/memory\";\n\nconst nonceStore = memoryNonceStore({\n  ttl: 5 * 60_000, // milliseconds (default: 5 minutes)\n  maxSize: 10_000, // optional FIFO bound\n});\n```\n\n### Redis Store\n\nBring your own client (ioredis, node-redis, or @upstash/redis). Uses `SET key 1 NX EX \u003cttl\u003e` for an atomic insert-if-absent in a single round-trip.\n\n```ts\nimport Redis from \"ioredis\";\nimport { redisStore } from \"hono-dpop/stores/redis\";\n\nconst nonceStore = redisStore({\n  client: new Redis(process.env.REDIS_URL!),\n  ttl: 300,           // seconds, default 300\n  keyPrefix: \"dpop:jti:\",\n});\n```\n\n### Cloudflare KV Store\n\nWorks 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.\n\n```ts\nimport { kvStore } from \"hono-dpop/stores/cloudflare-kv\";\n\n// inside a Workers fetch handler with a KV binding `NONCE_KV`\nconst nonceStore = kvStore({ namespace: env.NONCE_KV });\n```\n\n### Cloudflare D1 Store\n\nSQLite-backed strong consistency on Workers. Auto-creates the table on first use; call `purge()` from a scheduled handler to reclaim expired rows.\n\n```ts\nimport { d1Store } from \"hono-dpop/stores/cloudflare-d1\";\n\n// inside a Workers fetch handler with a D1 binding `DB`\nconst nonceStore = d1Store({ database: env.DB });\n// optional: scheduled() { await nonceStore.purge(); }\n```\n\n### Durable Objects Store\n\nPer-object single-writer guarantee → atomic without explicit locks. Ideal for per-tenant or per-key isolation.\n\n```ts\nimport { durableObjectStore } from \"hono-dpop/stores/durable-objects\";\n\n// inside a Durable Object class\nconst nonceStore = durableObjectStore({ storage: this.ctx.storage });\n```\n\n### Custom Store\n\n```ts\nimport type { DPoPNonceStore } from \"hono-dpop\";\n\nconst customStore: DPoPNonceStore = {\n  // Atomically: returns true if jti was NOT seen, false if already seen.\n  async check(jti, expiresAt) { /* ... */ },\n  async purge() { /* return number of removed entries */ },\n};\n```\n\n### Nonce Provider (RFC 9449 §8)\n\nOptional. When set, the middleware emits `use_dpop_nonce` challenges and validates the `nonce` claim on subsequent proofs.\n\n```ts\nimport { dpop, memoryNonceProvider } from \"hono-dpop\";\n\napp.use(\"/api/*\", dpop({\n  nonceStore: memoryNonceStore(),\n  nonceProvider: memoryNonceProvider({\n    rotateAfter: 5 * 60_000, // ms (default: 5 min)\n    retainPrevious: true,    // accept the previous nonce too (default: true)\n  }),\n}));\n```\n\nFor multi-instance deployments, implement `NonceProvider` against a shared store:\n\n```ts\nimport type { NonceProvider } from \"hono-dpop\";\n\nconst customProvider: NonceProvider = {\n  async issueNonce(c) { /* return a fresh nonce string */ },\n  async isValid(nonce, c) { /* return true for currently/recently valid nonces */ },\n};\n```\n\n## Benchmarks\n\n```bash\npnpm vitest bench\n```\n\nRepresentative numbers from a recent run (Apple M-series, Node 24):\n\n```\nparseProof                       ~370,000 ops/sec\njwkThumbprint ES256              ~130,000 ops/sec\nverifyProofSignature RS256       ~ 22,000 ops/sec\nverifyProofSignature ES256       ~ 12,000 ops/sec\nverifyProofSignature ES512       ~  1,100 ops/sec\nmemoryNonceStore.check (10k)   ~1,800,000 ops/sec\n```\n\nThe store is a Map lookup, so its throughput is independent of population. Verification cost is dominated by the curve / modulus.\n\n## Accessing the Verified Proof in Handlers\n\n```ts\nimport type { DPoPEnv } from \"hono-dpop\";\nimport { Hono } from \"hono\";\n\nconst app = new Hono\u003cDPoPEnv\u003e();\n\napp.get(\"/api/me\", (c) =\u003e {\n  const proof = c.get(\"dpop\");\n  if (!proof) return c.text(\"no proof\", 401);\n  return c.json({\n    jkt: proof.jkt, // SHA-256 JWK thumbprint (RFC 7638), base64url\n    jti: proof.jti,\n    htm: proof.htm,\n    htu: proof.htu,\n    iat: proof.iat,\n    ath: proof.ath,\n  });\n});\n```\n\n## What this middleware does NOT do\n\n- 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.\n- It does **not** verify multi-segment proxies. Use `getRequestUrl` to provide the canonical external URL when behind a reverse proxy.\n\n## Documentation\n\n- [ADR-0001: No jose dependency](./docs/adr/0001-no-jose-dependency.md)\n- [ADR-0002: RFC 9457 Problem Details](./docs/adr/0002-rfc-9457-problem-details.md)\n- [ADR-0003: 401 for all proof failures](./docs/adr/0003-401-for-all-proof-failures.md)\n- [ADR-0004: jti replay via pluggable store](./docs/adr/0004-jti-replay-via-pluggable-store.md)\n- [Threat model](./docs/security/threat-model.md)\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpaveg%2Fhono-dpop","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpaveg%2Fhono-dpop","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpaveg%2Fhono-dpop/lists"}