Authenticate workloads with SPIFFE
A workload with a SPIFFE identity can get a token with its JWT-SVID instead of a client secret. You register nothing for it. The server creates a client for each SPIFFE ID the first time it sees one, and policy grants it access by its SPIFFE path.
We ran every request on this page against our own instance, with a small server standing in for SPIRE that published one key. We did not run it against a real SPIRE deployment. The integration test test_spiffe_identity.py covers the same flow, and all 14 of its tests passed.
Point the server at your trust bundle
The server reads the bundle URL from spiffe_trust_bundle_url in the trust row, pbac.operator.controls.trust. No environment variable sets it. The URL must return a JWK set, such as the /keys endpoint of the SPIRE OIDC discovery provider, and the server must be able to reach it.
The trust row also holds trusted_issuers for software statements. A PUT replaces the whole row, so send both.
issuer=$(curl -fsS "$POLICYARC_URL/.well-known/openid-configuration" | jq -r .issuer)
curl -fsS -X PUT "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.trust" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d "{\"dataKey\": \"pbac.operator.controls.trust\", \"payload\": {\"trusted_issuers\": [\"$issuer\"], \"spiffe_trust_bundle_url\": \"https://spire-oidc.example/keys\"}}"
Until the URL is set, a JWT-SVID is treated as an ordinary client assertion and refused with invalid_request, client_id is required when using client_assertion.
The server caches the bundle for PBAC_SPIFFE_TRUST_BUNDLE_REFRESH_SECONDS, 300 by default. It fetches again early when an SVID names a key it does not have. If a fetch fails, it keeps using the keys it already has.
Grant workloads by path
The SPIFFE row, pbac.operator.controls.spiffe_policy, says which trust domains to accept and what each path may do.
curl -fsS -X PUT "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.spiffe_policy" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"dataKey": "pbac.operator.controls.spiffe_policy", "payload": {
"trust_domains": ["example.org"],
"path_grants": {"/agents/reporter": {"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:mcp:server", "scopes": ["mcp:tools"]}]}}
}}'
| Setting | Effect |
|---|---|
trust_domains | Only these trust domains get tokens. An empty or missing list accepts every domain the bundle's keys sign for. |
path_grants | Keyed by the path of the SPIFFE ID. Each value is an entitlement. grant_types defaults to client_credentials. |
A workload whose path has no entry is refused with reason spiffe_no_path_grants. A workload outside trust_domains is refused with spiffe_untrusted_domain. Both reach the client as HTTP 400 access_denied, and the reason is in the /token audit row.
Get a token
The workload sends its JWT-SVID as a client assertion. It sends no client_id.
curl -fsS "$POLICYARC_URL/token" \
-d grant_type=client_credentials \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode "client_assertion=$JWT_SVID" \
-d scope=mcp:tools -d resource_types=urn:mcp:server
We got {"access_token": "...", "token_type": "Bearer", "expires_in": 3600, "scope": "mcp:tools"}.
The SVID must meet these rules, or the answer is HTTP 401 invalid_client.
subis a SPIFFE ID, starting withspiffe://.- The signature verifies with a key from the bundle.
expis present and in the future.audcontains the server's SPIFFE audience. That isPBAC_SPIFFE_AUDIENCE, which defaults to the issuer. Mint the SVID for that audience. We sent an SVID for another audience and gotJWT-SVID aud claim does not contain expected audience.
What the server creates
On first use the server creates a client whose client_id is the SPIFFE ID and whose registration_type is SPIFFE_SVID. It records the trust domain, the path, its segments, and when it first and last saw the workload. Policy reads the trust domain and path at input.context.client.spiffe. The client is created before policy runs, so a workload that policy refuses still gets a client row.
Stop a workload
To refuse one workload at once, add its SPIFFE ID to client_ids in the denylist row, pbac.operator.controls.denylist. We did, and its next token request was refused with reason client_denied. The client endpoints of the Admin API cannot address a SPIFFE client by its ID, because the ID contains slashes. A GET with the ID encoded returned HTTP 400.
An SVID can also act as the actor in token exchange, with actor_token_type set to urn:ietf:params:oauth:token-type:jwt.
Send feedback about this page