Use on Cloudflare Workers

Protect a Cloudflare Worker endpoint with x401.

@proof.com/x401-workers turns a Cloudflare Worker into an x401 Verifier: a request arriving without a verified Digital ID gets a 401 carrying a presentation request, and your handler runs only once a presentation has been verified.

User agentYour WorkerNonce storeGET /protected1mint nonce2401 + PROOF-REQUEST3Wallet presents credential4GET /protected + PROOF-RESPONSE5Verify vp_token6consume nonce7200 + protected resource8

Before you start:

  • A domain you control, serving your Worker over HTTPS.
  • An email address on that domain.
  • A D1 database or KV namespace bound to the Worker.
npm install @proof.com/x401-workers

Verification uses Node built-ins, so nodejs_compat is required.

{
  "compatibility_flags": ["nodejs_compat"],
  "d1_databases": [
    { "binding": "DB", "database_name": "my-api", "database_id": "…" },
  ],
}

1. Generate a key pair

node -e 'const k = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); console.log(JSON.stringify(k.privateKey.export({ format: "jwk" })))' > signing-key.json
wrangler secret put SIGNING_JWK < signing-key.json

The key is ES256 on P-256. Its public half is published in your client metadata document; its private half stays a Worker secret.

2. Create the nonce table

CREATE TABLE x401_nonces (
  nonce TEXT PRIMARY KEY,
  expires_at INTEGER NOT NULL
);

CREATE INDEX x401_nonces_expires_at ON x401_nonces (expires_at);

Add it to your D1 migrations. kvNonceStore uses a KV namespace instead.

3. Protect your endpoint

import {
  agentsTrustListUrl,
  clientMetadataDocument,
  d1NonceStore,
  withX401,
  type X401Options,
} from "@proof.com/x401-workers";

type Env = { DB: D1Database; SIGNING_JWK: string };

const options = (env: Env): X401Options => ({
  clientId: "https://example.com/.well-known/proof-client.json",
  privateKeyFactory: () => JSON.parse(env.SIGNING_JWK),
  nonceStore: d1NonceStore(env.DB),
  scope: "urn:proof:params:scope:verifiable-credentials:basic",
  expectedOrigins: [agentsTrustListUrl("production")],
});

export default {
  async fetch(request: Request, env: Env) {
    const { pathname } = new URL(request.url);
    if (pathname === "/.well-known/proof-client.json") {
      return clientMetadataDocument({ ...options(env), clientName: "My API" });
    }
    if (pathname === "/protected") {
      return withX401(
        async (_request, presentation) =>
          Response.json(presentation.proof_id_default[0]?.toJSON()),
        () => options(env),
      )(request);
    }
    return new Response("Not found", { status: 404 });
  },
};

clientId is the URL your client metadata document is served from.

Hono

import { Hono } from "hono";
import {
  agentsTrustListUrl,
  clientMetadataDocument,
  d1NonceStore,
  type X401Options,
} from "@proof.com/x401-workers";
import { x401, type X401Variables } from "@proof.com/x401-workers/hono";

type Env = { DB: D1Database; SIGNING_JWK: string };

const options = (env: Env): X401Options => ({
  clientId: "https://example.com/.well-known/proof-client.json",
  privateKeyFactory: () => JSON.parse(env.SIGNING_JWK),
  nonceStore: d1NonceStore(env.DB),
  scope: "urn:proof:params:scope:verifiable-credentials:basic",
  expectedOrigins: [agentsTrustListUrl("production")],
});

const app = new Hono<{ Bindings: Env; Variables: X401Variables }>();

app.get("/.well-known/proof-client.json", (c) =>
  clientMetadataDocument({ ...options(c.env), clientName: "My API" }),
);
app.use(
  "/protected/*",
  x401<{ Bindings: Env }>((c) => options(c.env)),
);
app.get("/protected", (c) => c.json(c.var.x401.proof_id_default[0]?.toJSON()));

export default app;

The middleware sets the verified presentation as c.var.x401.

4. Register your OAuth Client ID Metadata Document

Enroll on Proof as an x401 Verifier by registering your CIMD, then follow the instructions received by email to complete your enrollment.

npx @proof.com/proof-vc-server enroll https://example.com/.well-known/proof-client.json --email [email protected] --key signing-key.json
📘

Email address

The email address must be on the registrable domain of your CIMD's host. www. is treated as the apex domain. For example, [email protected] works for https://example.com, https://www.example.com and https://app.example.com.

Nonce storage

Each challenge mints a nonce that the answering presentation consumes, so a presentation works once.

  • d1NonceStore(db) — the x401_nonces D1 table. Strictly single use. 600 s TTL, ttlSeconds to change.
  • kvNonceStore(kv) — a KV namespace. 600 s TTL, 60 s floor, ttlSeconds to change. KV replicates between locations within the minute, so a retry landing elsewhere inside that window may be rejected or replayed.

Responses

Requests without a PROOF-RESPONSE header receive a PROOF-REQUEST header in the response, that carries a presentation request.

HTTP/1.1 401 Unauthorized
proof-request: <base64url presentation request>
cache-control: no-store

An invalid presentation gets a PROOF-RESULT header.

HTTP/1.1 401 Unauthorized
content-type: text/plain; charset=utf-8
cache-control: no-store
proof-result: <base64url error object>

The challenge nonce is unknown, expired or already used

Base64url-decoded, the PROOF-RESULT header holds:

{
  "scheme": "x401",
  "version": "0.2.0",
  "error": "invalid_nonce",
  "error_description": "The challenge nonce is unknown, expired or already used"
}

Resources


Did this page help you?