npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

hono-dpop

v0.3.0

Published

DPoP (RFC 9449) proof-of-possession middleware for Hono.

Readme

hono-dpop

npm version CI codecov License: MIT

DPoP (RFC 9449) proof-of-possession middleware for Hono.

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 <token> 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

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

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

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 <token>`
  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

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 and include the RFC 9449 §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="<space-separated>" parameter on WWW-Authenticate so clients can discover supported algorithms without trial-and-error (RFC 9449 §7.1).

When 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.

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 <ttl> for an atomic insert-if-absent in a single round-trip.

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.

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.

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.

import { durableObjectStore } from "hono-dpop/stores/durable-objects";

// inside a Durable Object class
const nonceStore = durableObjectStore({ storage: this.ctx.storage });

Custom Store

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.

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:

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

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

import type { DPoPEnv } from "hono-dpop";
import { Hono } from "hono";

const app = new Hono<DPoPEnv>();

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

License

MIT