Agent attestation
POST /orgs/{orgId}/api/v1/agents/{agentId}/attest
Attestation means the agent proves where it runs: it presents an OIDC token issued by the platform it runs on (GitHub Actions, GCP, AWS IRSA, Kubernetes projected SA, Azure, SPIFFE, a CI/CD provider or any OIDC issuer). LumoAuth verifies the signature against the provider's JWKS, matches the subject against the agent's registered workload identity, and returns a short-lived access token. No static client secret is ever stored in the agent's environment.
The endpoint is anonymous — the attestation token is the credential — and is rate limited per client IP and per agent (30 requests per minute each; 429 with Retry-After when spent).
This is the "real" workload identity flow that eliminates the bootstrap-secret problem.
Do this first
-
Register the agent (AI Agents → Agents → Register agent) with Workload identity as its identity type, pick the provider under Where it runs and fill in the binding fields. The form shows the audience the platform has to mint the token for (copy button next to it) — by default the agent's own URL,
https://<your-deployment>/orgs/<orgId>/api/v1/agents/<agentId>. See the provider matrix.
-
In the workload, fetch the platform's OIDC token with that audience. Fetch a fresh token for every attestation: each token is accepted once (see Token rules).
-
POST /orgs/{orgId}/api/v1/agents/{agentId}/attestwith{"attestation_token": "<JWT>"}. -
Use the returned
access_tokenas a Bearer token on agent, JIT (just-in-time permission) and authz routes. It is stateless: no session is created and the token must be sent on every call (see Agent API keys and Bearer tokens). -
Re-attest before
expires_inruns out.
Request
POST /orgs/acme-corp/api/v1/agents/agt_42/attest
Content-Type: application/json
{
"attestation_token": "<JWT issued by the platform>"
}
Response
{
"access_token": "eyJraWQi…",
"token_type": "Bearer",
"expires_in": 900,
"scope": "reports.send wires.create",
"identity": {
"provider": "github",
"subject": "github:acme-corp/data-pipeline:refs/heads/main",
"issuer": "https://token.actions.githubusercontent.com"
}
}
The access token is short-lived (15 min). The agent re-attests as needed; each attestation is an independent OIDC verification, so a rotated cluster signing key, a revoked GitHub token, or a deleted workload all immediately invalidate future attestations.
Provider matrix
The portal and the admin API share one mapping from the fields you type to the exact workloadIdentity subject and workloadIdentityConfig keys the verifier parses, so a binding saved in the portal is a binding the verifier can match. Every provider stores an audience; when you give none, the agent-specific default is written.
workloadIdentityProvider | Issuer used | workloadIdentityConfig keys (bold = required) | Binding subject (workloadIdentity) |
|---|---|---|---|
github | https://token.actions.githubusercontent.com | audience, repository, ref, environment, repository_id, repository_owner, repository_owner_id, job_workflow_ref, sub, allow_any_ref | github:owner/repo[:refs/heads/main] |
gcp | https://accounts.google.com | audience, service_account_email, project_id | gcp:sa@project.iam.gserviceaccount.com |
aws | your EKS cluster's OIDC issuer URL | issuer, audience, namespace, service_account | aws:irsa:namespace/serviceaccount |
k8s | your cluster's OIDC issuer URL | issuer, audience, namespace, service_account | k8s:namespace/serviceaccount |
azure | https://login.microsoftonline.com/<tenant_id>/v2.0 | audience, tenant_id, client_id, object_id, allow_tenant_wide | azure:<tenant_id>:<client_id> (or azure:<tenant_id> with allow_tenant_wide: true) |
spiffe | your SPIRE OIDC Discovery Provider URL (or a static jwks_uri) | trust_domain, audience, and issuer or jwks_uri | the SPIFFE ID: spiffe://trust-domain/path |
custom (Any OIDC issuer) | the issuer you configure | issuer, audience, jwks_uri, claims ({claim: expected}) | the exact sub claim |
gitlab, azure_devops, circleci, buildkite, terraform_cloud, vercel, cloudflare | see Workload identity federation | per provider; gitlab also takes project_id | per provider |
Every optional key is an exact-match pin on the corresponding token claim: the token is rejected when the claim differs. Pins are checked before the subject is matched, so you can bind to github:acme/agents and still require environment: production.
Audience
The aud of the token must contain the configured audience, byte for byte. The default is agent-specific:
https://<your-deployment>/orgs/<orgId>/api/v1/agents/<agentId>
That value is the URL of the agent's own API, so a token minted for it cannot be redeemed for any other agent or service. The portal shows it read-only with a copy button; the admin API writes it when workloadIdentityConfig.audience is omitted.
You can override it — some platforms cannot mint a custom audience (the IRSA token for sts.amazonaws.com, Azure Pipelines' api://AzureADTokenExchange). An audience that is not hosted on your deployment is accepted but flagged: the portal shows a warning on save, the admin API returns workloadAudienceGeneric: true, and every attest response carries identity.audience_generic: true. Any token the platform mints for that audience, for any relying party, satisfies the agent — pin everything else you can.
Recommended cloud-side configuration:
| Provider | How to mint the token for the agent audience |
|---|---|
| GitHub Actions | permissions: id-token: write, then core.getIDToken('<audience>') (actions/github-script) or curl "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=<audience>" |
| Kubernetes / EKS IRSA | a second projected volume: serviceAccountToken: { audience: <audience>, expirationSeconds: 600, path: lumoauth-token } |
| GCP | curl -H 'Metadata-Flavor: Google' 'http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=<audience>&format=full' |
| Azure | expose the audience as an app registration's Application ID URI and request a token for it from the managed identity endpoint (resource=<audience>) |
| SPIRE | spire-agent api fetch jwt -audience <audience> |
| Any OIDC issuer | configure the client/application so the ID token's aud is the audience |
Subject format
The verifier normalizes the token into one subject string and compares it with the agent's registered workloadIdentity:
| Provider | Subject |
|---|---|
| GitHub | github:owner/repo:refs/heads/branch |
| K8s | k8s:namespace/serviceaccount |
| AWS IRSA | aws:irsa:namespace/serviceaccount |
| GCP | gcp:sa@project.iam.gserviceaccount.com |
| Azure | azure:<tenant_id>:<client_id or object_id> |
| SPIFFE | the SPIFFE ID itself: spiffe://trust-domain/path |
| Any OIDC issuer | the sub claim verbatim |
Prefix narrowing. Registering github:acme-corp/data-pipeline accepts every branch, tag and pull request under that repo; registering github:acme-corp/data-pipeline:refs/heads/main requires exactly main. Prefixes end at a : boundary, so github:acme/agents never matches github:acme/agents-evil.
- SPIFFE identities narrow at path boundaries instead:
spiffe://prod.acme.com/agentsacceptsspiffe://prod.acme.com/agents/report-runnerbut neverspiffe://prod.acme.com/agents-evil. - Azure: a bare
azure:<tenant_id>accepts every principal of the directory and is only honoured whenallow_tenant_wide: trueis set (the portal has an explicit switch). Without it the attestation is rejected. - Any OIDC issuer (
custom): subjects are issuer-specific strings with no safe boundary, so the match is always exact.
Pinning GitHub Actions
The repo-only binding is labelled Accept any branch, tag or pull request in the portal and has to be switched on explicitly; by default the form asks for a ref (refs/heads/main) or an environment. For production agents pin as much as you can:
| Pin | Why |
|---|---|
ref (refs/heads/main) or environment | a workflow on another branch — or a fork pull request — cannot act as the agent |
repository_id, repository_owner_id | survive a rename or transfer; a squatter can take an old repository name but not its id |
job_workflow_ref | pins the reusable workflow file and the ref it ran from |
sub | the full subject when you customised it in the repository OIDC settings |
GitLab offers the same with project_id + ref; Vercel bindings default to the production environment (preview deployments run untrusted pull-request code; enter any to accept every environment).
Token rules
Applied to every provider after the signature check:
| Rule | Detail |
|---|---|
exp required | a token without exp is rejected even if validly signed |
| Lifetime cap | the token must expire within 24 hours (WorkloadAttestationService::MAX_REMAINING_LIFETIME_SECONDS). Kubernetes projected tokens requested with a long expirationSeconds are refused — request ten minutes, not a week. |
| One-time use | a token is accepted once. The replay key is (organization, issuer, jti); issuers that mint no jti (Kubernetes, EKS IRSA, some GCP flows) fall back to a hash of the whole token. The key lives until the token's exp. Fetch a fresh token for every attestation — re-reading the same projected token file, or a metadata-server response the platform cached, yields attestation_rejected on the second call. |
| Status | the agent must be active and the organization active; a suspended or revoked agent is rejected before any verification and its attestation freshness is not refreshed |
| JWKS | signing keys are fetched over HTTPS through an SSRF guard, cached for 15 minutes, capped at 256 KiB and must be served as JSON. A kid the cached document does not know triggers one refetch (key rotation); a key id still unknown afterwards is negative-cached for a minute. |
SPIFFE / SPIRE (JWT-SVID)
Any workload with a SPIFFE identity (a vendor-neutral workload identity standard) can attest with a JWT-SVID — no cloud provider required. LumoAuth validates the SVID per the SPIFFE JWT-SVID profile: signature against your trust domain's keys, sub = the SPIFFE ID (must match the agent's registered binding, path-boundary prefixes allowed), aud must contain the configured audience (SVIDs are audience-scoped by design), exp required, and only the RS256 / ES256 / ES384 / PS256 algorithms are accepted.
1. Expose your trust domain's keys
The recommended source is the SPIRE OIDC Discovery Provider, which serves /.well-known/openid-configuration for your trust domain. LumoAuth performs standard OIDC discovery against it and fetches jwks_uri (SSRF-guarded, 15-minute key cache). Deployments without the discovery provider can point LumoAuth at a static HTTPS JWKS URL instead.
2. Register the agent binding
workloadIdentityConfig for a spiffe agent:
{
"workloadIdentityProvider": "spiffe",
"workloadIdentity": "spiffe://prod.acme.com/agents/report-runner",
"workloadIdentityConfig": {
"trust_domain": "prod.acme.com",
"issuer": "https://oidc-discovery.prod.acme.com",
"audience": "https://app.lumoauth.dev/orgs/acme-corp"
}
}
trust_domain(required) — tokens whose SPIFFE ID belongs to any other trust domain are rejected, even if validly signed.issuerorjwks_uri(one required) — SPIRE OIDC Discovery Provider base URL, or a direct JWKS document URL.audience(required) — the value your workloads pass when fetching the SVID.
All three (plus the SPIFFE ID binding) are enforced at agent create/update time, so a misconfigured agent fails fast with HTTP 400 rather than failing every attestation.
3. Fetch a JWT-SVID and attest
# Inside the workload (SPIRE agent socket mounted):
SVID=$(spire-agent api fetch jwt \
-audience https://app.lumoauth.dev/orgs/acme-corp \
-socketPath /run/spire/sockets/agent.sock | sed -n '2p' | tr -d ' ')
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/agents/agt_42/attest \
-H 'Content-Type: application/json' \
-d "{\"attestation_token\": \"$SVID\"}"
The response is the same short-lived bearer token as every other provider, with identity.provider = "spiffe" and identity.subject set to the SPIFFE ID.
Errors
| HTTP | error | Meaning |
|---|---|---|
| 401 | attestation_rejected | Bad signature, wrong audience, untrusted issuer, missing or too-distant exp, replayed token, subject mismatch, no workload identity registered on the agent, or the agent / organization is not active. The body is always the same generic message (below). |
| 403 | access_policy_denied | The workload token verified, but the agent's conditional-access policy denied it (e.g. risk ceiling, IP allow-list). The reason field carries the policy reason code (attestation_stale, risk_level_exceeded, ip_not_allowed, ...). |
| 404 | tenant_not_found | No organization matches orgId |
| 400 | invalid_json | Body is not a JSON object |
| 400 | missing_attestation_token | Body did not include attestation_token |
| 429 | rate_limit_exceeded | More than 30 attestations per minute from one IP, or for one agent; honour Retry-After |
An unknown agentId is answered exactly like a failed attestation (401), so agent ids cannot be probed.
A rejected attestation returns a static body that does not say why:
{
"error": "attestation_rejected",
"error_description": "The workload attestation token could not be verified for this agent."
}
The specific cause (which claim mismatched, which issuer or subject was expected) names the agent's registered binding, so it is written only to the server log and to the organization's audit log as agent.attestation.rejected. It is never returned to the caller. The same applies to the token-exchange path on /oauth/token: a rejected workload token simply fails the grant.
Troubleshooting
attestation_rejectedand you do not know why. Open Audit logs and filter foragent.attestation.rejected; thereasonin the event metadata names the failing check. Operators can also read theagent_attestation rejectedwarning in the server log, which includes the underlying cause.- Audience mismatch. The
audyou pass when minting the cloud token must equal theaudienceinworkloadIdentityConfig, byte for byte. - Subject mismatch. Compare the normalized subject in the subject format table with the agent's registered
workloadIdentity; a registered prefix must end at a:(or a/for SPIFFE) boundary. Bindings saved before the portal and verifiers shared one mapping (a role ARN, a raw service-account email,system:serviceaccount:ns:sa) are upgraded the next time the agent is saved — open the agent, check the binding, save. - Second attestation with the same token is rejected. Tokens are one-time use. Request a new token per attestation; for Kubernetes re-read the projected file only after kubelet has rotated it, or request one through the TokenRequest API.
Azure tenant-wide binding … requires allow_tenant_widein the audit log: the agent is bound toazure:<tenant_id>without the explicit opt-in. Add the client ID, or switch on Accept every identity in this directory deliberately.- Token lifetime too long. The platform token expires more than 24 hours out; shorten
expirationSeconds(Kubernetes) or the issuer's token lifetime. - Never attested, now denied by policy. An access policy with Require attestation within (hours) denies agents that have never attested. The policy is evaluated before the attestation timestamp is updated, so the request that would satisfy it is still denied; disable the policy for the first attestation or attest once before enabling it.
Why this matters
Without workload attestation, agents need a static client secret somewhere. Static secrets are easily exfiltrated, rarely rotated, and grant indefinite access. Workload attestation:
- No secret in env — the agent's only credential is its cloud-issued OIDC token, which the cloud rotates automatically.
- Tied to runtime — a leaked token is useless outside the workload it was issued for, because OIDC tokens are short-lived and bound to specific audiences.
- Auditable — every attestation event records the provider, subject, and issuer in your organization's audit log.
See also
- Workload identity federation — the conceptual overview
- Push approval for agents — human-in-the-loop on top of attested identity