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.
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-workersVerification 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.jsonThe 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 addressThe 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 forhttps://example.com,https://www.example.comandhttps://app.example.com.
Nonce storage
Each challenge mints a nonce that the answering presentation consumes, so a presentation works once.
d1NonceStore(db)— thex401_noncesD1 table. Strictly single use. 600 s TTL,ttlSecondsto change.kvNonceStore(kv)— a KV namespace. 600 s TTL, 60 s floor,ttlSecondsto 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-storeAn 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 usedBase64url-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
- x401 — the protocol this implements.
- proof/x401-workers — source, and the full option list.
- Verify a Credential — the scopes and claims you can ask for.
Updated about 3 hours ago