Federated workload identity for AI agents
An agent normally authenticates with a credential (agt_…) that sits in your CI secrets, a
Kubernetes Secret, or an env var — indefinitely. Federated workload identity removes it:
your runtime proves what it is with the attestation it already has (a GitHub Actions
OIDC token, a Kubernetes projected service-account token, cloud instance identity, a SPIFFE
JWT-SVID), and Zynth exchanges that directly for the same short-lived agent token.
No Zynth secret at rest, anywhere. There is nothing to leak, nothing to rotate, and nothing whose theft is indistinguishable from the agent. The token that does briefly exist can additionally be sender-constrained with DPoP, making it useless if stolen.
Availability: feature-flagged per deployment (
AGENT_FEDERATION_ENABLED). If the Attestation issuers section of the AI Agents console shows a "not enabled" card, ask your platform operator. The classic credential path always keeps working — bare-metal environments have no attestation source, and that is fine.
How trust is scoped (read this once)
- You register the issuers your organization trusts, per issuer profile (GitHub Actions, Kubernetes, AWS, GCP, SPIFFE). Each profile enforces its own signature algorithms, maximum token lifetime, and key-discovery posture — see the profile table.
- A binding maps exact attestation claims to one pre-registered agent. No wildcards — they cannot even be saved. A token matching zero bindings, or more than one, is refused identically.
- Every attestation must be minted for your Zynth deployment (its
audis checked both ways), is accepted exactly once (single-usejti), and every registry change raises an immediate Command Center detection.
1. Register the issuer and bind your agent
AI Agents → Attestation issuers → Register issuer, pick the profile, then Bind agent with the exact claims. For GitHub Actions the subject claim looks like:
repo:<owner>/<repo>:ref:refs/heads/<branch>
Bind exactly the repository and ref your workflow runs from. If the console refuses your binding, read the message — it names the rule (a wildcard character, a missing subject claim, a claim outside the profile's schema) and the fix is always to be more specific.
2. Exchange from GitHub Actions
Grant the job an OIDC token minted for your deployment and exchange it:
permissions:
id-token: write # lets the job request an OIDC token
steps:
- name: Get an agent token (no secrets configured anywhere)
run: |
ATTESTATION=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://auth.example.com" | jq -r .value)
curl -sSf -X POST https://auth.example.com/api/v1/agents/token/federated \
-H "Content-Type: application/json" \
-d "{\"tenant\": \"acme-inc\", \"subject_token\": \"$ATTESTATION\"}"
The response is the standard agent token
({access_token, token_type, expires_in, agent_id}). Note the two things you supply:
audience = your deployment's URL (the exchange refuses tokens minted for anyone
else), and tenant = your organization slug (an attestation carries no Zynth tenant,
so the request names it).
With the TypeScript SDK, the whole loop — including transparent re-exchange with a fresh attestation each time — is:
import { AgentSession, githubActionsIdToken } from "@zynth/auth-client";
const agent = new AgentSession({
baseUrl: "https://auth.example.com",
federated: {
tenant: "acme-inc",
getSubjectToken: githubActionsIdToken("https://auth.example.com"),
},
});
await agent.callTool("github", "search", { q: "zynth" });
3. Exchange from Kubernetes
Project a service-account token with your deployment as the audience — the kubelet rotates it for you:
volumes:
- name: zynth-attestation
projected:
sources:
- serviceAccountToken:
path: token
audience: https://auth.example.com
expirationSeconds: 3600
Then federated: { tenant, getSubjectToken: kubernetesProjectedToken("/var/run/secrets/zynth/token") }.
The kubernetes profile requires the issuer's JWKS URI pinned at registration (your
cluster's discovery document usually isn't reachable from outside) — the console asks for
it up front.
Harden with DPoP
Send a DPoP proof with the exchange and the issued
agent token binds to your run's ephemeral key — stolen alone, it is useless. In the SDK,
pass fetch: createDpopFetch(await createDpopKeypair()) to the AgentSession. Federation
removes the secret at rest; DPoP makes the remaining short-lived token theft-resistant.
Together they are the strongest agent-identity posture the platform offers.
Troubleshooting
Every refusal is the same generic 401 — deliberately, so the exchange can't be used to probe your configuration. The real reason is visible to your admins in the Command Center (repeated failures raise a detection naming it). The usual causes, in order:
audmismatch — your attestation was minted withoutaudience=set to the Zynth deployment URL. This is the most common one.- No unique binding — the token's claims match zero bindings (be less specific in the token or fix the binding) or more than one (delete the overlap; ambiguity never resolves to "first match").
- Replay — each attestation exchanges once. Mint a fresh one per exchange; the SDK's provider callback does this for you.
- Issuer disabled / unknown tenant / retired agent — all read identically from outside; check the registry and the agent's status in the console.
- 429 with
Retry-After— the fail-closed exchange throttles; back off and retry.