Workload Identity (Secretless Client Authentication)
A service_account client normally authenticates with a stored client_secret. Workload identity removes the stored secret entirely: the workload authenticates at POST /oauth/token with a signed JWT it already possesses — a Kubernetes projected ServiceAccount token or a SPIFFE JWT-SVID — presented as an RFC 7523 client_assertion. Nothing to distribute, rotate, or leak.
The assertion works as client authentication on both machine grants: client_credentials and token exchange.
Request
No client_id and no client_secret — the client is derived from the trusted issuer the assertion's iss resolves to. Presenting a client_assertion together with any other authentication method is rejected (RFC 6749 §2.3).
| Parameter | Value |
|---|---|
client_assertion_type | urn:ietf:params:oauth:client-assertion-type:jwt-bearer (K8s SA tokens, cloud/generic OIDC tokens) or urn:ietf:params:oauth:client-assertion-type:jwt-spiffe (SPIFFE JWT-SVIDs) |
client_assertion | The external JWT itself |
Trusted issuers (admin API)
An assertion is only accepted if its iss matches a trusted issuer the admin has registered and bound to a specific service account. Super-admin GraphQL operations (also on AuthorizerAdminService gRPC/REST):
| Operation | Type | Purpose |
|---|---|---|
_add_trusted_issuer | mutation | Register an issuer for a service account |
_update_trusted_issuer | mutation | Update name, jwks_url, expected_aud, allowed_subjects, is_active |
_delete_trusted_issuer | mutation | Remove the issuer |
_trusted_issuer | query | Fetch one by id |
_trusted_issuers | query | Paginated list, optionally filtered by service_account_id |
Fields:
| Field | Notes |
|---|---|
service_account_id | Internal id of the service_account client this issuer authenticates |
issuer_url | Must equal the assertion's iss claim exactly. Globally unique across all trusted issuers (including per-org SSO connections). Under static_jwks_url this is a matching key, not an address — Authorizer never fetches it. Only oidc_discovery dials it (for {issuer_url}/.well-known/openid-configuration). That is what lets a private cluster issuer like https://kubernetes.default.svc work with a mirrored JWKS |
key_source_type | oidc_discovery (fetch jwks_uri from {issuer_url}/.well-known/openid-configuration) or static_jwks_url (fetch jwks_url directly — required when the issuer's discovery document is not reachable) |
jwks_url | Required for static_jwks_url |
expected_aud | The aud the assertion must contain exactly — set it to your Authorizer URL and mint tokens with that audience, so a token minted for another service can never be replayed here |
subject_claim | Claim that identifies the workload; defaults to sub |
allowed_subjects | Comma-separated exact-match subject allow-list. Empty = deny-all — a row with no subjects authenticates nobody |
issuer_type | kubernetes_sa | spiffe_jwt | oidc | cloud_oidc |
JWKS/discovery fetches use an SSRF-hardened HTTP client — host-pinned, redirects refused, response size capped, and private/loopback/link-local addresses rejected. Whatever Authorizer fetches must therefore be publicly routable, which for Kubernetes depends entirely on the cluster's issuer — see Kubernetes ServiceAccount tokens.
spiffe_bundle_endpointhas no implementation and is rejected at write time withkey_source_type "spiffe_bundle_endpoint" is not implemented yet— useoidc_discoveryorstatic_jwks_urlfor SPIFFE issuers.spiffe_refresh_hint_secondsis stored but not yet honoured at runtime.
Validation rules
Every check is fail-closed, and every rejection returns the same generic invalid_client so no check leaks which one failed:
| Check | Rule |
|---|---|
| Algorithm | Asymmetric only (RS*, PS*, ES*); alg:none and HS* rejected (RFC 8725) |
| Signature | Verified against the issuer's JWKS (cached 10 minutes) |
| Issuer | iss must resolve to an active client-assertion trust row — a per-org SSO issuer at the same URL can never authenticate an OAuth client |
| Audience | aud must contain the row's expected_aud exactly |
| Lifetime | exp and iat required; declared lifetime (exp − iat) must be ≤ 1 hour; exp/nbf/iat checked with 60 s clock skew |
| Subject | subject_claim value must exactly match an allowed_subjects entry (never prefix/substring); empty list is deny-all |
| Replay | Assertions are single-use — keyed by jti, or by (iss, sub, iat, exp) when the issuer omits one (RFC 7523 permits it), held until the token's exp. Kubernetes projected ServiceAccount tokens do carry a jti and key on it |
| Type match | jwt-bearer assertions only match non-SPIFFE rows; jwt-spiffe only matches spiffe_jwt rows |
| Bound client | Must exist, be active, and be a service_account |
Because assertions are single-use, mint a fresh platform token per token-endpoint call (Kubernetes TokenRequest API, SPIFFE Workload API) rather than re-presenting a cached one.
Kubernetes ServiceAccount tokens
Kubernetes clusters are OIDC issuers: projected ServiceAccount tokens are JWTs signed by the cluster, with iss = the cluster's issuer URL and sub = system:serviceaccount:<namespace>:<name>.
Does my cluster work out of the box?
Three independent questions decide it, and they have different answers on the same cluster — the issuer being private does not make the apiserver private, or vice versa. Check both addresses first:
kubectl get --raw /.well-known/openid-configuration | jq -r '.issuer, .jwks_uri'
kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}'; echo
Then read off each column independently. Authorizer's SSRF guard refuses private, loopback and link-local addresses, so "reachable" throughout means publicly routable from Authorizer.
oidc_discovery | static_jwks_url | TokenReview | |
|---|---|---|---|
| Decided by | is the issuer URL reachable? | is any copy of the JWKS reachable? | is the apiserver reachable? |
EKS / GKE / AKS (public issuer, e.g. https://oidc.eks.<region>.amazonaws.com/id/…) | ✅ | ✅ not needed | ✅ with a public API endpoint |
Self-managed, custom public issuer (--service-account-issuer=https://…) | ✅ | ✅ not needed | depends on the endpoint |
Default issuer — https://kubernetes.default.svc.cluster.local (kubeadm, kind, k3d) | ❌ discovery lives at a private URL | ✅ point it at a reachable copy — the apiserver itself if that is public, otherwise a mirror | depends on the endpoint |
| Any cluster, private API endpoint only | per the issuer, above | per the JWKS, above | ❌ and there is no workaround — leave enable_token_review off |
Two things worth reading off that table, because they are easy to get backwards:
- A private issuer does not mean the feature is unavailable. It rules out
oidc_discoveryonly.issuer_urlis matched against the token'sissand never fetched understatic_jwks_url, so it can stay as the cluster's own unroutable value while the keys come from anywhere reachable. - A private issuer does not imply a private apiserver. A kubeadm cluster on the default issuer can still have a publicly-reachable control-plane endpoint, in which case TokenReview works and
static_jwks_urlcan point straight at<apiserver>/openid/v1/jwkswith no mirror at all.
If your issuer is an https:// URL on a public domain, the first two rows apply and registration is a two-field job.
Clusters on the default issuer
A cluster left on the upstream default publishes https://kubernetes.default.svc.cluster.local as its issuer. That name is a cluster-internal DNS record, served by CoreDNS to pods only — .cluster.local is not a public zone, and nothing outside the cluster resolves it. So oidc_discovery is out, because the discovery document lives under that URL.
It fails for a different reason depending on where Authorizer runs, which is worth knowing because the error text differs:
| Authorizer runs | What happens | Error you see |
|---|---|---|
| Outside the cluster | The name does not resolve at all (NXDOMAIN) | failed to resolve host |
| Inside the cluster | CoreDNS resolves it to the kubernetes Service ClusterIP — 10.96.0.1 on a default --service-cluster-ip-range — which is RFC 1918 | requests to private/internal networks are not allowed |
That leaves static_jwks_url, and the only question is whether Authorizer can reach a copy of the cluster's keys. Check the apiserver first: if your control-plane endpoint is publicly reachable, point jwks_url straight at https://<apiserver>/openid/v1/jwks and skip the rest of this section. A mirror is only needed when it is not — a kind or k3d cluster, or any control plane on a private network.
The fix needs no code and no exception: publish the cluster's public keys somewhere reachable and point jwks_url at that. It works because issuer_url is only matched against the token's iss and is never dialed, so it can stay as the cluster's own unroutable issuer. This is the same shape AWS IRSA uses — the JWKS in public object storage, the apiserver never exposed.
# 1. Export the cluster's PUBLIC keys. Nothing secret is in this document.
kubectl get --raw /openid/v1/jwks > jwks.json
# 2. Host it anywhere publicly reachable — object storage, your CDN, a static host.
aws s3 cp jwks.json s3://my-bucket/clusters/prod/jwks.json --acl public-read
mutation {
_add_trusted_issuer(
params: {
service_account_id: "CLIENT_UUID"
name: "prod-cluster payments-worker"
# The cluster's own issuer — matched against `iss`, never fetched.
issuer_url: "https://kubernetes.default.svc.cluster.local"
key_source_type: "static_jwks_url"
jwks_url: "https://my-bucket.s3.amazonaws.com/clusters/prod/jwks.json"
expected_aud: "https://your-authorizer.example"
allowed_subjects: "system:serviceaccount:payments:worker"
issuer_type: "kubernetes_sa"
}
) { id }
}
A mirror is only as current as whatever refreshes it. Kubernetes rotates ServiceAccount signing keys, and a stale mirror fails in both directions: tokens signed with a new key stop validating (an outage), and a retired key that is still published keeps validating tokens it should not (a security gap).
Authorizer caches a fetched JWKS for 10 minutes, so its own staleness is bounded — the mirror's is not. Refresh it as part of whatever rotates the cluster keys, or on a schedule shorter than your rotation period. If you cannot commit to that, prefer a cluster with a public issuer.
1. Register the trusted issuer
For a cluster with a public issuer (the common case), oidc_discovery needs no JWKS handling at all:
mutation {
_add_trusted_issuer(
params: {
service_account_id: "CLIENT_UUID" # the service_account client's internal id
name: "prod-cluster payments-worker"
issuer_url: "https://oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLE"
key_source_type: "oidc_discovery"
expected_aud: "https://your-authorizer.example"
allowed_subjects: "system:serviceaccount:payments:worker"
issuer_type: "kubernetes_sa"
}
) {
id
issuer_url
is_active
}
}
2. Project a token with the right audience
volumes:
- name: authorizer-token
projected:
sources:
- serviceAccountToken:
path: authorizer-token
audience: https://your-authorizer.example # must equal expected_aud
expirationSeconds: 3600 # ≤ the 1-hour lifetime ceiling
3. Authenticate
JWT=$(cat /var/run/secrets/tokens/authorizer-token)
curl -s -X POST $AUTHORIZER_URL/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "client_assertion=$JWT" \
-d "scope=read:payments"
For testing: kubectl create token worker -n payments --audience=https://your-authorizer.example --duration=10m.
Online hardening: Kubernetes TokenReview
Offline JWKS validation proves the token was signed by the cluster — not that the bound Pod/ServiceAccount still exists. A deleted pod's not-yet-expired token would still verify. For high-security workloads a trust row can opt in to online validation via enable_token_review + kubernetes_api_server_url — admin-settable fields on _add_trusted_issuer / _update_trusted_issuer (kubernetes_api_server_url must be a non-empty https URL whenever enable_token_review is true, checked at write time): after the offline checks pass, Authorizer calls the cluster's authentication.k8s.io/v1 TokenReview API and rejects the assertion fail-closed unless the apiserver reports it still authenticated.
mutation {
_update_trusted_issuer(
params: {
id: "TRUSTED_ISSUER_UUID"
enable_token_review: true
kubernetes_api_server_url: "https://<managed-cluster-public-api-endpoint>"
}
) {
id
enable_token_review
kubernetes_api_server_url
}
}
- Authorizer authenticates the TokenReview call with its own in-cluster ServiceAccount token, which needs the
system:auth-delegatorClusterRole. - The apiserver URL goes through the same SSRF-hardened client, so only a publicly-routable apiserver endpoint works —
https://kubernetes.default.svc(a private ClusterIP) is rejected by design. Unlike key fetch, there is no mirror equivalent here: TokenReview is a live call to your apiserver. A cluster without a reachable API endpoint cannot use it, and should leaveenable_token_reviewoff — offline JWKS validation still authenticates the workload. kubernetes_api_server_urlis security-sensitive: Authorizer authenticates that call with its own in-cluster ServiceAccount token, so whatever host you configure receives that credential. Treat it as a trusted-host field, and keep Authorizer's ClusterRole tosystem:auth-delegator(TokenReview only) so the credential grants nothing else.
SPIFFE JWT-SVIDs (preview)
For workloads in a SPIFFE/SPIRE trust domain, the JWT-SVID is the client credential (per draft-ietf-oauth-spiffe-client-auth — an expired individual draft, so this ships as a preview). Two differences from the generic path:
client_assertion_typeisurn:ietf:params:oauth:client-assertion-type:jwt-spiffeand the trust row'sissuer_typemust bespiffe_jwt— the profiles cannot be crossed.issis the SPIRE server, andsubis the workload's SPIFFE ID (spiffe://…) —iss ≠ subis expected. The subject must be aspiffe://URI and appear exactly inallowed_subjects.
Register the issuer with issuer_url set to the SPIRE server's configured jwt_issuer and point the key source at the SPIRE OIDC Discovery Provider (oidc_discovery, or static_jwks_url at its keys endpoint), with issuer_type: "spiffe_jwt" and the workload's SPIFFE ID in allowed_subjects.
# Fetch a fresh JWT-SVID from the local SPIRE agent (each fetch mints a new one)
SVID=$(spire-agent api fetch jwt \
-audience https://your-authorizer.example -output json | jq -r '.[0].svids[0].svid')
curl -s -X POST $AUTHORIZER_URL/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" \
--data-urlencode "client_assertion=$SVID" \
-d "scope=read:payments"
Errors
| Error | Cause |
|---|---|
invalid_request | Unsupported client_assertion_type, or more than one client authentication method presented |
invalid_client | Any assertion validation failure (deliberately indistinguishable) |
unauthorized_client | The bound client is not a service_account for this grant |
invalid_scope | Requested scope outside the client's allowed_scopes |