skip to content

Verify assertions

Verify the Ed25519 assertion against the issuer JWKS, enforce issuer, audience, expiry, scopes, status and terms checks, and verify an optional credential.

awaiting reviewupdated view markdown ->
for humans

Goal: accept a compact Ed25519 JWT only when every check in spec section 7.1 passes, and map each failure to a fixed v1 code.

The claim set is in Assertion claims. This guide is the implementation step. Verifying an assertion needs no account and no SDK: it uses the human broker's public JWKS. An account is required only to be a verified provider, issue authenticated receipts, or report abuse with standing.

In v1.2 a provider may also publish an optional provider verification credential (the RP attestation). It is a separate, local check; it does not change how the assertion is verified.

Before you start

  • The issuer origin from your issuer_allowlist, for example https://agentregistrar.dev.
  • The published audience, required_scopes and current terms.sha256.
  • A place to record seen jti values, such as your account table.
  • jose 6.2.13, or an equivalent Ed25519 implementation. Nothing here requires a specific library.

The verifier

Save as verify.mjs. It verifies the signature first, then the claims, and throws an error whose code is the stable v1 code.

import { createHash } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";

const MAX_LIFETIME_SECONDS = 300;
const CLOCK_TOLERANCE_SECONDS = 60;

export async function verifyAssertion(jwt, config) {
  const fail = (code, description) => {
    const error = new Error(description);
    error.code = code;
    throw error;
  };

  let payload;
  try {
    const jwks = createRemoteJWKSet(new URL(config.jwksUrl), {
      timeoutDuration: 5000,
      cooldownDuration: 30000,
    });
    const result = await jwtVerify(jwt, jwks, {
      algorithms: ["EdDSA"],
      clockTolerance: CLOCK_TOLERANCE_SECONDS,
      typ: "agent-id+jwt",
    });
    payload = result.payload;
  } catch (error) {
    if (error.code === "ERR_JWT_EXPIRED") {
      fail("assertion_expired", "Assertion has expired");
    }
    fail("invalid_assertion", "Signature or JOSE header verification failed");
  }

  if (!config.issuerAllowlist.includes(payload.iss)) {
    fail("issuer_not_allowed", "Issuer is not in issuer_allowlist");
  }
  if (payload.aud !== config.audience) {
    fail("audience_mismatch", "Assertion audience does not match");
  }
  if (typeof payload.exp !== "number" || payload.exp - payload.iat > MAX_LIFETIME_SECONDS) {
    fail("invalid_assertion", "Assertion lifetime exceeds five minutes");
  }

  const scopes = payload.delegation?.scopes ?? [];
  if (!config.requiredScopes.every((scope) => scopes.includes(scope))) {
    fail("insufficient_scope", "Delegated scopes do not cover required_scopes");
  }
  if (payload.human?.verified !== true) {
    fail("human_unverified", "The human owner is not verified");
  }
  if (payload.delegation?.accepted_rp_tos_hash !== config.termsSha256) {
    fail("tos_hash_mismatch", "Accepted ToS hash does not match the published terms");
  }

  const accountKey = `${payload.iss}:${payload.human.id}`;
  const previous = config.seenJti.get(payload.jti);
  if (previous !== undefined && previous !== accountKey) {
    fail("assertion_replayed", "Assertion id was used in a conflicting context");
  }
  config.seenJti.set(payload.jti, accountKey);

  return { claims: payload, resolution: previous === undefined ? "created" : "resolved" };
}

export function sha256Text(text) {
  return "sha256:" + createHash("sha256").update(text, "utf8").digest("hex");
}

The seenJti map is a stand-in for a unique constraint in your account store. The semantics matter: the same jti for the same account resolves, a different account is a replay.

Try it locally

This fixture mints an assertion with a throwaway key. A provider never mints assertions and never holds a broker private key; the fixture exists only so the verifier can be exercised offline.

mkdir provider-verify
cd provider-verify
npm init -y
npm install jose@6.2.13

Save as mint-test-assertion.mjs:

import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs";
import { exportJWK, generateKeyPair, SignJWT } from "jose";

const { publicKey, privateKey } = await generateKeyPair("Ed25519");
const publicJwk = await exportJWK(publicKey);

const jwks = { keys: [{ ...publicJwk, kid: "test-key-1", alg: "EdDSA", use: "sig" }] };
writeFileSync("jwks.json", JSON.stringify(jwks, null, 2) + "\n");

const termsText = "Example RP Terms of Service v2026.1";
const tosHash = "sha256:" + createHash("sha256").update(termsText, "utf8").digest("hex");
const now = Math.floor(Date.now() / 1000);

const jwt = await new SignJWT({
  agent: { id: "agt_test", name: "test-agent", email: "test-agent@agents.example", harness: "test" },
  human: { id: "hum_test", name: "Test Human", email: "human@example.com", verified: true },
  delegation: {
    master_tos_version: "2026.1",
    accepted_rp_tos_url: "https://example.com/terms",
    accepted_rp_tos_hash: tosHash,
    accepted_at: now,
    scopes: ["tos:accept"],
  },
})
  .setProtectedHeader({ alg: "EdDSA", kid: "test-key-1", typ: "agent-id+jwt" })
  .setIssuer("https://agentregistrar.dev")
  .setSubject("agt_test")
  .setAudience("example-rp")
  .setJti("jti_test_0001")
  .setIssuedAt(now)
  .setExpirationTime(now + 300)
  .sign(privateKey);

writeFileSync("assertion.jwt", jwt + "\n");
console.log("wrote jwks.json and assertion.jwt");

Save as serve-jwks.mjs, so other recipes can point at the same fixture:

import { createServer } from "node:http";
import { readFileSync } from "node:fs";

const jwks = readFileSync("jwks.json", "utf8");
createServer((req, res) => {
  res.writeHead(200, { "content-type": "application/json" });
  res.end(jwks);
}).listen(3015, "127.0.0.1", () => {
  console.log("jwks on http://127.0.0.1:3015/.well-known/jwks.json");
});

Save as check.mjs:

import { readFileSync } from "node:fs";
import { sha256Text, verifyAssertion } from "./verify.mjs";

const jwt = readFileSync("assertion.jwt", "utf8").trim();
try {
  const result = await verifyAssertion(jwt, {
    jwksUrl: "http://127.0.0.1:3015/.well-known/jwks.json",
    issuerAllowlist: ["https://agentregistrar.dev"],
    audience: "example-rp",
    requiredScopes: ["tos:accept"],
    termsSha256: sha256Text("Example RP Terms of Service v2026.1"),
    seenJti: new Map(),
  });
  console.log(JSON.stringify({ outcome: "ok", resolution: result.resolution }));
} catch (error) {
  console.log(JSON.stringify({ outcome: "error", code: error.code }));
}

Generate the token and keys once:

node mint-test-assertion.mjs
wrote jwks.json and assertion.jwt

Run the JWKS fixture in one shell and the check in another:

node serve-jwks.mjs
jwks on http://127.0.0.1:3015/.well-known/jwks.json
node check.mjs

Recorded output on 2026-10-21:

{"outcome":"ok","resolution":"created"}

Failure cases

The verifier was run against synthetic variants of the same fixture. Each row is a recorded result, not an illustration.

Case Result
Valid assertion ok, resolution: "created"
Same jti, same account ok, resolution: "resolved"
Same jti, different human assertion_replayed
Wrong aud audience_mismatch
Stale or wrong ToS hash tos_hash_mismatch
human.verified: false human_unverified
Missing required scope insufficient_scope
Expired past the skew assertion_expired
Issuer not in the allowlist issuer_not_allowed
Tampered signature invalid_assertion
Lifetime over five minutes invalid_assertion

Checks and codes

Check Rule Failure code
Signature Verify against GET <iss>/.well-known/jwks.json; alg is EdDSA; the key matches kid; typ is agent-id+jwt. invalid_assertion (401)
Issuer iss equals an issuer_allowlist entry exactly. issuer_not_allowed (403)
Audience aud equals your published audience. audience_mismatch (403)
Expiry exp has not passed; allow at most 60 seconds of clock skew. assertion_expired (401)
Lifetime exp - iat is at most 5 minutes. invalid_assertion (401)
Replay A jti in a conflicting context is rejected; the same context resolves. assertion_replayed (409)
Scopes delegation.scopes covers every required_scopes entry. insufficient_scope (403)
Human human.verified === true. human_unverified (403)
Terms delegation.accepted_rp_tos_hash equals the current terms.sha256. tos_hash_mismatch (409)

Two checks are policy choices rather than fixed codes: the five-minute lifetime is the recommended maximum, and a JWKS transport failure is an operational error. Map the first to invalid_assertion and return a retryable 503-style response for the second without inventing a core code. The normative table is spec section 7.2.

Verify the provider verification credential

The credential is optional. Do this before signup when you want to check the provider's identity; skip it and the provider is unverified, not invalid. The normative steps are in spec section 14.

  1. Fetch discovery from the provider's canonical origin, GET /.well-known/agent-signup.json. The fetched origin is authoritative.
  2. Read rp_attestation, a compact JWS. If it is absent, record unverified and apply policy.
  3. Check the issuer against your trusted RP-broker list before any fetch. Do not contact an untrusted broker. If the issuer is not trusted, record attested_untrusted_broker.
  4. Resolve Broker B's JWKS. Use jwks_uri from /.well-known/agent-broker.json only when its issuer equals the attestation iss and the URI is HTTPS and same-origin; otherwise use <iss>/.well-known/jwks.json. Never merge JWKS from different issuers.
  5. Verify the signature with the key selected by kid. Require typ: agent-rp-attestation+jwt, alg: EdDSA, and exactly one kid.
  6. Check time and binding: iat/exp with 60 seconds of skew, non-empty jti, rp.origin equal to the fetched discovery origin, and rp.audience equal to discovery.audience.
  7. Record the level (domain or entity) and apply your policy. In v1.2 both levels are verified; the level is informational and displayed.
import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";

export async function verifyProviderCredential(attestation, discovery, config) {
  const { iss: issuer } = decodeJwt(attestation);
  if (!config.trustedRpBrokers.includes(issuer)) {
    return { status: "attested_untrusted_broker" };
  }
  const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`), {
    timeoutDuration: 5000,
    cooldownDuration: 300000,
  });
  try {
    const { payload } = await jwtVerify(attestation, jwks, {
      algorithms: ["EdDSA"],
      typ: "agent-rp-attestation+jwt",
      clockTolerance: 60,
    });
    if (payload.rp.origin !== discovery.__fetched_origin) {
      return { status: "invalid", reason: "origin_mismatch" };
    }
    if (payload.rp.audience !== discovery.audience) {
      return { status: "invalid", reason: "audience_mismatch" };
    }
    return { status: "verified", level: payload.rp.verification.level, jti: payload.jti };
  } catch (error) {
    return { status: error.code === "ERR_JWT_EXPIRED" ? "expired" : "invalid" };
  }
}

discovery.__fetched_origin is a placeholder for the canonical origin of the document you actually fetched. Never infer it from terms.url, provider_url, or the attestation itself. A JWKS fetch failure is unavailable, not invalid.

The broker's one-call identity_signup MCP tool does this check for you and returns rp_verification plus an optional rp_verification_warning. Use it when you do not need to inspect claims yourself. A provider does not need an account to accept an assertion; the credential only lets the agent check the provider.

See Trust and limits for the trust list, policy matrix, and why verification is identity, not safety.

JWKS caching and rotation

  • Fetch only GET <iss>/.well-known/jwks.json. Do not accept a jku or x5u header that points at another host.
  • Cache keys by URL and kid for minutes, not forever. createRemoteJWKSet caches and refreshes; a custom cache must do the same.
  • On an unknown kid, refresh once before rejecting. This handles broker key rotation without a restart.
  • Apply a timeout and a size cap to the JWKS request. A slow issuer must not hold your signup route open.
  • The broker currently publishes one key with kid broker-key-1. Do not hard-code it; read it from the JWKS.
  • Revocation is enforced by the broker at minting time. A verifier learns about revocation the next time an assertion is requested, not from the signature.

Inspection-only rule

Never decode, re-encode or modify the compact JWT to guess fields. The broker returns the exact signed payload as assertion.claims for inspection; the provider reads claims only from the verified token. Send assertion.jwt verbatim from the agent to your endpoint.

Verify your work

  • The verifier rejects a tampered token and accepts a valid one.
  • Each failure row above produces the listed code.
  • The JWKS URL is the issuer origin, not a request parameter.
  • Clock skew is bounded, and the lifetime check exists.
  • A repeated jti for the same account resolves instead of failing.
  • No identity field is read from the request body.

Next

sections

type to search.

up/down navigateenter selectesc close