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.
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.
404means no binding was ever published for that exact trust domain: refuse.200with anystatusother thanactive(suspended,expired,withdrawn, orinactivewith areason) carriesassertion: null: refuse. An HTTP 200 alone is never enough; requirestatus: "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_domainequals the peer's trust domain exactly (wrong_trust_domain);organization_idequals the ID you pinned for it (wrong_organization). Never accept "any listed organization", and never trustorganization_nameor a label the agent sends;- the peer's path is inside
workload_scope, matched by whole segment (path_out_of_scope):/paymentscovers/payments/apibut 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
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.