Preview — not for production reliance
Documentation

Docs For relying parties

Code examples

Verify a directory assertion in TypeScript with @art/verifier, in Go with the demo's artverify package, with the art-verify CLI, and look at the raw API with curl.

All four examples implement the verification steps and assume you have already pinned two things out of band: the directory key set (pinned-jwks.json) and, for each peer trust domain, the organization ID you expect.

Both reference verifiers live in the Agentic Root Trust repository: @art/verifier in packages/verifier (TypeScript library and CLI) and artverify in demo/relying-party/artverify (a Go port with the same order and reason codes). @art/verifier is a workspace package and is not published to npm.

TypeScript

verifyResolution(jws, options) runs every check, including the bundle digest, and returns { ok: true, payload } or { ok: false, reason }. It never throws for a bad token.

ts
import { readFileSync } from "node:fs";
import { decodeJwt } from "jose";
import { parseSpiffeId, verifyResolution } from "@art/verifier";

const ISSUER = "https://svid.io";
// Pinned out of band. Never fetched per request.
const JWKS = JSON.parse(readFileSync("pinned-jwks.json", "utf8"));
// Your own policy: the stable organization ID you expect for each peer trust domain.
const EXPECTED_ORG: Record<string, string> = {
  "agents.payments.example.com": "org_7k3m9q2x8d5f1h4j6n0p2r4t8v",
};

export async function checkPeer(spiffeId: string) {
  const id = parseSpiffeId(spiffeId); // { trustDomain, path } or null
  if (!id) return { ok: false, reason: "not_a_workload_spiffe_id" };
  const expectedOrganizationId = EXPECTED_ORG[id.trustDomain];
  if (!expectedOrganizationId) return { ok: false, reason: "peer_trust_domain_not_configured" };

  const res = await fetch(`${ISSUER}/v1/resolve?trust_domain=${encodeURIComponent(id.trustDomain)}`, {
    headers: { accept: "application/json" },
  });
  if (res.status === 404) return { ok: false, reason: "not_in_directory" };
  if (res.status !== 200) return { ok: false, reason: `directory_http_${res.status}` };
  const body = await res.json();
  if (body.status !== "active" || typeof body.assertion !== "string") {
    return { ok: false, reason: `binding_not_active:${body.status}` };
  }

  // The bundle URL is inside the token. Read it unverified, but only fetch from the pinned issuer origin;
  // verifyResolution then requires bundle_url to be exactly the canonical URL on that origin and the bytes
  // to match the signed bundle_sha256.
  const { bundle_url } = decodeJwt(body.assertion);
  if (typeof bundle_url !== "string" || !bundle_url.startsWith(`${ISSUER}/v1/issuer-bindings/`)) {
    return { ok: false, reason: "unexpected_bundle_url" };
  }
  const bundleRes = await fetch(bundle_url, { redirect: "error" });
  if (bundleRes.status !== 200) return { ok: false, reason: `bundle_http_${bundleRes.status}` };
  const bundleBytes = new Uint8Array(await bundleRes.arrayBuffer());

  const result = await verifyResolution(body.assertion, {
    jwks: JWKS,
    issuer: ISSUER,
    expectedOrganizationId,
    trustDomain: id.trustDomain,
    workloadPath: id.path,
    bundleBytes,
    // clockSkewS: 30 (default; 0 to 60)
  });
  // On success, validate the peer's X.509-SVID chain against the x509-svid authorities in bundleBytes,
  // then apply your own authorization.
  return result.ok ? { ok: true, payload: result.payload, bundleBytes } : result;
}

VerifyOptions also takes now (Unix seconds) for testing. Configuration mistakes fail closed too: a non-https issuer, a missing organization ID or bundle, or a skew outside 0 to 60 seconds return config_* reasons instead of accepting.

Go

The demo's artverify package splits verification in two so you learn bundle_url from a verified payload before fetching anything: VerifyAssertion runs every check except the digest, and CheckBundleDigest is the last step. This mirrors demo/relying-party/rp/directory.go.

go
import (
	"github.com/hassard0/agentic-root-trust/demo/relying-party/artverify"
	"github.com/spiffe/go-spiffe/v2/bundle/spiffebundle"
	"github.com/spiffe/go-spiffe/v2/spiffeid"
	"github.com/spiffe/go-spiffe/v2/svid/x509svid"
)

// pinned is an artverify.JWKS loaded once at startup from your pinned file.
func checkPeer(ctx context.Context, id spiffeid.ID, certs []*x509.Certificate, pinned artverify.JWKS, expectedOrg string) error {
	jws, err := resolve(ctx, id.TrustDomain().Name()) // GET /v1/resolve; 404 or a non-active status refuses
	if err != nil {
		return err
	}
	p, err := artverify.VerifyAssertion(jws, artverify.Options{
		JWKS:                   pinned,
		Issuer:                 "https://svid.io",
		ExpectedOrganizationID: expectedOrg,
		TrustDomain:            id.TrustDomain().Name(),
		WorkloadPath:           id.Path(),
	})
	if err != nil {
		return fmt.Errorf("assertion: %s", artverify.Reason(err)) // same reason codes as the TypeScript verifier
	}

	bundleBytes, err := fetch(ctx, p.BundleURL) // on the issuer origin, pinned by the verified schema
	if err != nil {
		return err
	}
	if err := artverify.CheckBundleDigest(p, bundleBytes); err != nil {
		return fmt.Errorf("bundle: %s", artverify.Reason(err)) // bundle_digest_mismatch
	}

	b, err := spiffebundle.Parse(id.TrustDomain(), bundleBytes)
	if err != nil || len(b.X509Authorities()) == 0 {
		return errors.New("bundle: no x509-svid authorities")
	}
	// The SVID chain against THAT bundle only.
	if _, _, err := x509svid.Verify(certs, b.X509Bundle(), x509svid.WithTime(time.Now())); err != nil {
		return fmt.Errorf("svid: %w", err)
	}
	return nil // now apply your local authorization for id
}

VerifyResolution(jws, opts, bundleBytes) runs both halves at once, like the TypeScript function. Options.ClockSkewS defaults to 30 (maximum 60). AllowHTTPIssuer exists only for the local demo, which runs over plain http.

The art-verify CLI

art-verify wraps the TypeScript verifier. Build it from the repository:

bash
npm install
npm run build -w @art/verifier          # writes packages/verifier/dist/art-verify.mjs

Then verify a resolved assertion against your pinned keys and the bundle bytes:

bash
TD=agents.payments.example.com
JWS=$(curl -s "https://svid.io/v1/resolve?trust_domain=$TD" | jq -r .assertion)
BUNDLE_URL=$(node -e 'console.log(JSON.parse(Buffer.from(process.argv[1].split(".")[1], "base64url")).bundle_url)' "$JWS")
curl -s "$BUNDLE_URL" -o bundle.json

node packages/verifier/dist/art-verify.mjs \
  --jwks pinned-jwks.json \
  --org org_7k3m9q2x8d5f1h4j6n0p2r4t8v \
  --spiffe-id "spiffe://$TD/agent/checkout" \
  --bundle bundle.json \
  "$JWS"

Pass - instead of the token to read it from standard input. Other options: --issuer (default https://svid.io), --now <unix seconds> and --skew <seconds>. It prints {"ok": true, "payload": …} or {"ok": false, "reason": "…"} and exits with 0 when verified, 1 when rejected and 2 for a usage or input error.

curl

curl shows what the API returns; it does not verify anything. Decoding a JWS is not verifying it.

bash
# Resolve a trust domain (never cached: Cache-Control: no-store)
curl -s "https://svid.io/v1/resolve?trust_domain=agents.payments.example.com"

# The assertion signing keys (pin these through a trusted channel)
curl -s https://svid.io/.well-known/jwks.json

# An approved bundle: immutable, ETag = bundle_sha256, so a revalidation returns 304
curl -si "https://svid.io/v1/issuer-bindings/<binding_id>/versions/<version>/bundle" \
  -H 'If-None-Match: "<bundle_sha256>"'

# The public change feed
curl -s "https://svid.io/v1/changes?limit=100"

An active answer looks like this (assertion shortened):

json
{
  "trust_domain": "agents.payments.example.com",
  "status": "active",
  "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFydC1yZXNvbHV0aW9uK2p3dDt2PTEi...",
  "expires_at": "2026-10-05T12:05:00.000Z",
  "checked_at": "2026-10-05T12:00:00.000Z"
}

and an inactive one like this:

json
{
  "trust_domain": "agents.payments.example.com",
  "status": "suspended",
  "reason": "binding_suspended",
  "assertion": null,
  "checked_at": "2026-10-05T12:00:00.000Z"
}