Preview — not for production reliance
Documentation

Docs For relying parties

Verification

The relying-party verification algorithm, step by step in the specified order, with pinned JWKS distribution and the 330-second withdrawal bound.

When an agent connects to you over mTLS and presents an X.509-SVID, the directory answers one question: which verified organization has authorized the issuer of this trust domain, with which bundle, for which workload paths? Your service then decides what the agent may do.

Every step below fails closed: stop at the first failure and refuse. The order is the one the reference verifiers (TypeScript and Go) implement, and their reason codes are given in brackets.

What happens when an agent connects Sequence: the agent opens an mTLS connection and presents its X.509-SVID. The relying party calls GET /v1/resolve for the trust domain; the directory answers status active with a signed assertion valid for at most 300 seconds. The relying party fetches the immutable bundle at bundle_url, then verifies the signature against its pinned keys, the issuer, audience and times, the pinned organization ID, the workload scope, the bundle digest and the SVID chain. Finally it applies its own policy and allows or denies the request. AI agent SPIFFE workload Relying party your API or service Directory svid.io ① mTLS handshake, presents its X.509-SVID spiffe://agents.payments.example.com/… ② GET /v1/resolve?trust_domain=… ③ status: active + signed assertion ES256, valid at most 300 s ④ GET bundle_url ⑤ bundle bytes (immutable, cacheable) ⑥ The relying party checks • signature against pinned JWKS • issuer, audience, times, schema • organization_id you pinned • SVID path inside workload_scope • SHA-256 of bundle = bundle_sha256 • SVID chains to that bundle ⑦ your own policy: allow or deny Fail closed: stop at the first failed check.
The whole exchange. The steps below detail check ⑥ in order.

Before you start: pin the directory keys

Assertions are signed with ES256 keys published at /.well-known/jwks.json. Do not fetch the JWKS per request, and never use a key the token names or links to. Load the key set once through a channel you trust, store it in your configuration, and verify every assertion against that pinned copy. The reference relying party in the demo reads a pinned jwks.json file at startup and never fetches keys afterwards.

Also pin, out of band:

  • the issuer: https://svid.io;
  • for each peer trust domain you accept, the stable organization ID you expect (org_…), taken from your own records, never from the peer.

Directory key rotation is planned so that pinning works: the operator distributes the new public key through the trusted channel before it signs anything, then switches signing to it, and keeps the old key published as verify-only for at least 330 seconds (300 s assertion lifetime plus 30 s skew) before retiring it. The JWKS response is cacheable for 300 seconds. A compromised directory key cannot authenticate its own replacement: that needs an out-of-band trust-anchor update.

During the preview there is no published out-of-band distribution channel for directory key updates. Treat the JWKS you pin as preview data.

The steps

0. Read the peer's SPIFFE ID. Take the trust domain and path from the leaf certificate's URI SAN, for example spiffe://agents.payments.example.com/agent/checkout. It only selects what to look up; nothing is trusted yet. Look up the organization ID you expect for that trust domain in your own configuration; if there is none, refuse.

1. Resolve. Call GET /v1/resolve?trust_domain=agents.payments.example.com.

  • 404 means no binding was ever published for that exact trust domain: refuse.
  • 200 with any status other than active (suspended, expired, withdrawn, or inactive with a reason) carries assertion: null: refuse. An HTTP 200 alone is never enough; require status: "active" and an assertion.
  • Responses are Cache-Control: no-store. Do not cache them.

2. Check the JWS shape and header. The token is three base64url parts, at most 16 KiB. The protected header may contain only alg, kid and typ (unexpected_header); jku, jwk, x5u, x5c, x5t, x5t#S256, crit and b64 are always refused (forbidden_header). Require alg = ES256 (alg_not_allowed), typ = art-resolution+jwt;v=1 (wrong_typ) and a kid (missing_kid).

3. Verify the signature with the pinned key. Exactly one pinned key must have that kid (unknown_kid); it must be EC P-256 with no private member (pinned_jwks_invalid). Verify the ES256 signature (bad_signature). The payload must be canonical JSON, exactly as the signer emits it: no whitespace, duplicate keys or byte-order mark (non_canonical_payload).

4. Check issuer and audience. iss must equal your pinned issuer (wrong_issuer) and aud must equal workload-directory-resolver-v1 (wrong_audience).

5. Check the times, with at most 30 seconds of skew (the reference verifiers allow 0 to 60): exp must be later than now minus skew (expired), and nbf and iat no later than now plus skew (not_yet_valid, issued_in_future).

6. Check profile and schema. profile must be one you support; today that is only spiffe-x509 (unsupported_profile). Then validate the payload strictly (invalid_payload:<reason>): exactly the documented fields and no others, schema_version 1, status active, nbf equal to iat, a lifetime of at most 300 seconds, policy_version directory-policy-v1, the fixed evidence_summary, and a bundle_url that is exactly <iss>/v1/issuer-bindings/<binding_id>/versions/<binding_version>/bundle. Unknown fields fail closed.

7. Check the binding against what you expected.

  • trust_domain equals the peer's trust domain exactly (wrong_trust_domain);
  • organization_id equals the ID you pinned for it (wrong_organization). Never accept "any listed organization", and never trust organization_name or a label the agent sends;
  • the peer's path is inside workload_scope, matched by whole segment (path_out_of_scope): /payments covers /payments/api but not /payments-evil.

8. Check the bundle digest. Fetch bundle_url (on the issuer's origin, by step 6) and compute the unpadded base64url SHA-256 of the exact bytes. It must equal bundle_sha256 (bundle_digest_mismatch). Bundles are immutable and served with Cache-Control: public, max-age=31536000, immutable and an ETag of the digest, so you may cache them by URL and digest.

9. Validate the SVID against that bundle. Verify the peer's X.509-SVID chain against the x509-svid authorities of that bundle and nothing else, for example with go-spiffe's x509svid.Verify. The mTLS handshake itself proves the peer holds the SVID's private key.

10. Apply your own authorization. Only now decide whether this SPIFFE ID may perform the requested operation. The directory's answer is identity and authorization of the issuer, never permission for the transaction.

Keeping a verified assertion

You may keep a validated assertion and re-check it until its exp, but no longer: resolve again after that. The reference relying party re-runs the whole check on every HTTP request, using the retained assertion while it still verifies, so even a long-lived connection stops working within the withdrawal bound.

The 330-second bound

How fast a withdrawal takes effect Timeline. At t equals 0 the withdrawal commits. From then on, resolve answers withdrawn with no assertion, and a public binding.withdrawn event appears in the change feed so pollers drop the trust domain. An assertion read just before the commit still verifies until its own expiry: at most 300 seconds, plus 30 seconds of verifier clock skew, so nothing verifies after 330 seconds. Resolve answers withdrawn, no assertion, from the next read Change feed binding.withdrawn published; pollers drop the domain t = 0: commit 300 s 330 s Old assertions an assertion read just before t = 0 still verifies until its exp none left Worst case: 300 s assertion lifetime + 30 s verifier clock skew = 330 s after the commit. Re-resolve after exp; never cache longer.
After a withdrawal commits, nothing a correct verifier holds stays valid for more than 330 seconds.

When an organization withdraws a binding, the directory answers only after the withdrawal commits, and from then on /v1/resolve never returns an active assertion for it. An assertion signed from a read just before the commit lives at most 300 seconds, and your 30 seconds of skew can extend that. So no relying party following these steps accepts a withdrawn binding more than 330 seconds after the withdrawal commits. The same bound applies to suspension and expiry. If you need a tighter bound, use a smaller skew and resolve on every handshake.

Withdrawal stops the directory's endorsement; it does not revoke SVIDs. SVID lifetimes are the issuer's responsibility.

Old bundles are not authority

Superseded bundle versions stay fetchable forever at their own URL. Validate SVIDs only against the bundle whose bytes hash to the bundle_sha256 of an assertion you have just verified. Holding an old bundle does not mean the binding is still endorsed.