Skip to main content
Version: 2.x (Latest)

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).

ParameterValue
client_assertion_typeurn: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_assertionThe 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):

OperationTypePurpose
_add_trusted_issuermutationRegister an issuer for a service account
_update_trusted_issuermutationUpdate name, jwks_url, expected_aud, allowed_subjects, is_active
_delete_trusted_issuermutationRemove the issuer
_trusted_issuerqueryFetch one by id
_trusted_issuersqueryPaginated list, optionally filtered by service_account_id

Fields:

FieldNotes
service_account_idInternal id of the service_account client this issuer authenticates
issuer_urlMust 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_typeoidc_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_urlRequired for static_jwks_url
expected_audThe 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_claimClaim that identifies the workload; defaults to sub
allowed_subjectsComma-separated exact-match subject allow-list. Empty = deny-all — a row with no subjects authenticates nobody
issuer_typekubernetes_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_endpoint has no implementation and is rejected at write time with key_source_type "spiffe_bundle_endpoint" is not implemented yet — use oidc_discovery or static_jwks_url for SPIFFE issuers. spiffe_refresh_hint_seconds is 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:

CheckRule
AlgorithmAsymmetric only (RS*, PS*, ES*); alg:none and HS* rejected (RFC 8725)
SignatureVerified against the issuer's JWKS (cached 10 minutes)
Issueriss must resolve to an active client-assertion trust row — a per-org SSO issuer at the same URL can never authenticate an OAuth client
Audienceaud must contain the row's expected_aud exactly
Lifetimeexp and iat required; declared lifetime (exp − iat) must be ≤ 1 hour; exp/nbf/iat checked with 60 s clock skew
Subjectsubject_claim value must exactly match an allowed_subjects entry (never prefix/substring); empty list is deny-all
ReplayAssertions 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 matchjwt-bearer assertions only match non-SPIFFE rows; jwt-spiffe only matches spiffe_jwt rows
Bound clientMust 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_discoverystatic_jwks_urlTokenReview
Decided byis 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 neededdepends on the endpoint
Default issuerhttps://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 mirrordepends on the endpoint
Any cluster, private API endpoint onlyper the issuer, aboveper 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_discovery only. issuer_url is matched against the token's iss and never fetched under static_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_url can point straight at <apiserver>/openid/v1/jwks with 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 runsWhat happensError you see
Outside the clusterThe name does not resolve at all (NXDOMAIN)failed to resolve host
Inside the clusterCoreDNS resolves it to the kubernetes Service ClusterIP — 10.96.0.1 on a default --service-cluster-ip-range — which is RFC 1918requests 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 }
}
Refresh the mirror when the cluster rotates its keys

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-delegator ClusterRole.
  • 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 leave enable_token_review off — offline JWKS validation still authenticates the workload.
  • kubernetes_api_server_url is 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 to system: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_type is urn:ietf:params:oauth:client-assertion-type:jwt-spiffe and the trust row's issuer_type must be spiffe_jwt — the profiles cannot be crossed.
  • iss is the SPIRE server, and sub is the workload's SPIFFE ID (spiffe://…) — iss ≠ sub is expected. The subject must be a spiffe:// URI and appear exactly in allowed_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

ErrorCause
invalid_requestUnsupported client_assertion_type, or more than one client authentication method presented
invalid_clientAny assertion validation failure (deliberately indistinguishable)
unauthorized_clientThe bound client is not a service_account for this grant
invalid_scopeRequested scope outside the client's allowed_scopes