skip to content

Hosting: Next.js

A complete Next.js App Router recipe for discovery and brokered signup, tested with Next.js 16.4.2.

awaiting reviewupdated view markdown ->
for humans

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

The 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.md

Discovery 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 3003

Expected, 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/null

Recorded headers:

HTTP/1.1 200 OK
access-control-allow-origin: *
cache-control: public, max-age=60
content-type: application/json

Validate it:

pnpm --silent validate:discovery http://127.0.0.1:3003/.well-known/agent-signup.json \
  --json --probe-jwks --probe-endpoints

Recorded result on 2026-10-21:

valid: true, exitCode: 0, passed: 21, failed: 0, warnings: 0, probes: 2

The 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 3003

Then, 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_URL for 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_SHA256 is unset, remove context rather than shipping an empty digest.

Next

sections

type to search.

up/down navigateenter selectesc close