Tested: this recipe ran end to end with Next.js 16.4.2 on Node 24.21.0 on 2026-10-21, using a local JWKS fixture. It is the one hosting recipe with a recorded platform run. The wire behavior is the same as any other host.
Create the app
mkdir provider-nextjs
cd provider-nextjs
npm init -y
npm pkg delete main
npm pkg set type=module
npm install next@16.4.2 react@19.3.1 react-dom@19.3.1 jose@6.2.13The two npm pkg lines are required. npm init -y writes "type": "commonjs" and "main": "index.js", so the bundler loads a .js route as CommonJS; the ESM export in the route then fails and the discovery URL returns 500 with Specified module format (CommonJs) is not matching the module format of the source code (EcmaScript Modules). Deleting main and setting "type": "module" makes route.js load as an ES module.
Set the terms file and a guide. The terms bytes below are the same bytes the verify-assertions fixture signs, so the recorded signup responses reproduce. The guide is optional; omit CONTEXT_SHA256 if you do not publish one.
printf 'Example RP Terms of Service v2026.1' > terms.txt
mkdir -p app/.well-known/agent-signup.json app/api/signup/agent public/docs
printf '# Example RP agent guide\n\nCall POST /api/provision with your API key.\n' > public/docs/SKILL.mdDiscovery route
Save as app/.well-known/agent-signup.json/route.js:
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
// In a real deployment the terms text comes from your content store; this
// example reads a file that ships with the app.
const termsText = readFileSync(join(process.cwd(), "terms.txt"), "utf8");
const sha256 = "sha256:" + createHash("sha256").update(termsText, "utf8").digest("hex");
export function GET() {
return Response.json(
{
protocol: "agent-identity-v1",
audience: process.env.PROVIDER_AUDIENCE ?? "example-rp",
provider_name: "Example RP",
provider_url: process.env.PROVIDER_URL ?? "https://example.com",
contact: process.env.PROVIDER_CONTACT ?? "security@example.com",
issuer_allowlist: [process.env.BROKER_ORIGIN ?? "https://agentregistrar.dev"],
required_scopes: ["tos:accept"],
terms: {
version: process.env.TERMS_VERSION ?? "2026.1",
url: process.env.TERMS_URL ?? "https://example.com/terms",
sha256,
text: termsText,
},
endpoints: {
agent_signup: `${process.env.PROVIDER_URL ?? "https://example.com"}/api/signup/agent`,
},
},
{
headers: {
"cache-control": "public, max-age=60",
"access-control-allow-origin": "*",
},
},
);
}Response.json sets content-type: application/json. The route has no auth and no redirect.
Signup route
Save as app/api/signup/agent/route.js. It verifies with jose, resolves accounts idempotently and returns credentials only on creation.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createRemoteJWKSet, jwtVerify } from "jose";
const BROKER_ORIGIN = process.env.BROKER_ORIGIN ?? "https://agentregistrar.dev";
const AUDIENCE = process.env.PROVIDER_AUDIENCE ?? "example-rp";
const JWKS_URL = process.env.BROKER_JWKS_URL ?? `${BROKER_ORIGIN}/.well-known/jwks.json`;
const termsText = readFileSync(join(process.cwd(), "terms.txt"), "utf8");
const TERMS_SHA256 = "sha256:" + createHash("sha256").update(termsText, "utf8").digest("hex");
const REQUIRED_SCOPES = ["tos:accept"];
const jwks = createRemoteJWKSet(new URL(JWKS_URL), { timeoutDuration: 5000 });
const accounts = new Map();
const seenJti = new Map();
function error(status, code, description) {
return Response.json({ error: code, error_description: description, retryable: false }, { status });
}
async function verifyAssertion(request) {
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) {
return { error: error(400, "invalid_request", "Missing bearer assertion") };
}
let claims;
try {
const result = await jwtVerify(header.slice(7), jwks, {
algorithms: ["EdDSA"],
clockTolerance: 60,
typ: "agent-id+jwt",
});
claims = result.payload;
} catch (cause) {
const code = cause?.code === "ERR_JWT_EXPIRED" ? "assertion_expired" : "invalid_assertion";
return { error: error(401, code, "Assertion rejected") };
}
if (claims.iss !== BROKER_ORIGIN) return { error: error(403, "issuer_not_allowed", "Issuer not allowed") };
if (claims.aud !== AUDIENCE) return { error: error(403, "audience_mismatch", "Wrong audience") };
if (claims.exp - claims.iat > 300) return { error: error(401, "invalid_assertion", "Lifetime too long") };
if (!REQUIRED_SCOPES.every((scope) => claims.delegation?.scopes?.includes(scope))) {
return { error: error(403, "insufficient_scope", "Scopes do not cover required_scopes") };
}
if (claims.human?.verified !== true) return { error: error(403, "human_unverified", "Human not verified") };
if (claims.delegation?.accepted_rp_tos_hash !== TERMS_SHA256) {
return { error: error(409, "tos_hash_mismatch", "Accepted ToS hash is stale") };
}
return { claims };
}
export async function POST(request) {
const verified = await verifyAssertion(request);
if (verified.error) return verified.error;
const { claims } = verified;
const accountKey = `${claims.iss}:${claims.human.id}`;
const previous = seenJti.get(claims.jti);
if (previous !== undefined && previous !== accountKey) {
return error(409, "assertion_replayed", "Assertion id used in a conflicting context");
}
seenJti.set(claims.jti, accountKey);
const existing = accounts.get(accountKey);
if (existing) {
return Response.json({
resolution: "resolved",
account_id: existing.accountId,
context: {
url: `${process.env.PROVIDER_URL ?? "https://example.com"}/docs/SKILL.md`,
sha256: process.env.CONTEXT_SHA256,
},
});
}
const account = {
accountId: `acct_${crypto.randomUUID()}`,
apiKey: `sk_live_${crypto.randomUUID()}`,
};
accounts.set(accountKey, account);
return Response.json(
{
resolution: "created",
account_id: account.accountId,
api_key: account.apiKey,
context: {
url: `${process.env.PROVIDER_URL ?? "https://example.com"}/docs/SKILL.md`,
sha256: process.env.CONTEXT_SHA256,
},
},
{ status: 201 },
);
}The in-memory maps are a demo. Use your database with a unique constraint on the account key and the jti.
Run it
export PROVIDER_URL=http://127.0.0.1:3003
export CONTEXT_SHA256=$(node -e 'const {readFileSync}=require("fs");const {createHash}=require("crypto");console.log("sha256:"+createHash("sha256").update(readFileSync("public/docs/SKILL.md")).digest("hex"))')
npx next dev -p 3003Expected, in part:
- Local: http://localhost:3003
- Ready in ...Fetch the document:
curl -sS -D - http://127.0.0.1:3003/.well-known/agent-signup.json -o /dev/nullRecorded headers:
HTTP/1.1 200 OK
access-control-allow-origin: *
cache-control: public, max-age=60
content-type: application/jsonValidate it:
pnpm --silent validate:discovery http://127.0.0.1:3003/.well-known/agent-signup.json \
--json --probe-jwks --probe-endpointsRecorded result on 2026-10-21:
valid: true, exitCode: 0, passed: 21, failed: 0, warnings: 0, probes: 2The endpoint probe records HEAD 405, which the validator accepts as reachable without a side effect.
Test signup
The local fixture in Verify assertions writes assertion.jwt and runs a JWKS server on port 3015. Start it, then point the app at it with BROKER_JWKS_URL and restart:
export BROKER_JWKS_URL=http://127.0.0.1:3015/.well-known/jwks.json
npx next dev -p 3003Then, from the fixture directory:
curl -sS -X POST http://127.0.0.1:3003/api/signup/agent \
-H "Authorization: Bearer $(cat assertion.jwt)"
curl -sS -X POST http://127.0.0.1:3003/api/signup/agent \
-H "Authorization: Bearer $(cat assertion.jwt)"Recorded output:
{"resolution":"created","account_id":"acct_1ce347cd-3b3b-4953-96f6-026de8494222","api_key":"sk_live_541450bd-5f13-4fc8-934c-0b80defe97d7","context":{"url":"http://127.0.0.1:3003/docs/SKILL.md","sha256":"sha256:28aca7f5116b57e227477c1f9f7cdc04aaadf36c9ce12d84041172a28ac5afd1"}}
{"resolution":"resolved","account_id":"acct_1ce347cd-3b3b-4953-96f6-026de8494222","context":{"url":"http://127.0.0.1:3003/docs/SKILL.md","sha256":"sha256:28aca7f5116b57e227477c1f9f7cdc04aaadf36c9ce12d84041172a28ac5afd1"}}The second call is a success and returns no credential. That is the resolve rule, not an error.
Notes for production
- Replace the in-memory maps with durable storage and a unique constraint.
- Read the terms from versioned content, not a file that can change under a running deploy.
- Keep
BROKER_JWKS_URLfor local testing only; production uses<issuer>/.well-known/jwks.json. - Set the signing key for receipts in a secret manager; the sample does not include a receipt.
- Do not cache the signup response. It contains a show-once credential.
- The context hash has no default value here: if
CONTEXT_SHA256is unset, removecontextrather than shipping an empty digest.
Next
- Hosting: Cloudflare Worker: the same flow on Workers.
- Hosting: generic HTTP: framework-neutral code.
- Issue a signup receipt: add Contract E.