You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs(workload-identity): make the Kubernetes story accurate (#92)
* docs(workload-identity): make the Kubernetes story accurate
Verified against server source, not against prior docs.
Whether this feature works on a cluster is decided by one thing: the
issuer that cluster publishes. EKS, GKE and AKS publish a public https
issuer with public OIDC discovery by default, so they work with no
configuration beyond registering the trusted issuer. Only clusters left
on the upstream default (kubeadm, kind, k3d) publish private addresses
the SSRF guard refuses. The page did not distinguish these, so an
operator could not tell which case they were in.
Adds:
- A matrix keyed on `kubectl get --raw /.well-known/openid-configuration`,
so an operator self-diagnoses in one command.
- The JWKS-mirror recipe for default-issuer clusters, with the reason it
works: issuer_url is matched against `iss` and NEVER fetched under
static_jwks_url — only oidc_discovery dials it. Pinned server-side by
TestPrivateIssuerURLIsNeverDialedUnderStaticJWKS.
- A rotation warning on the mirror. It fails in both directions: a stale
mirror stops validating new tokens, and one still serving a retired key
keeps validating tokens it should not. Authorizer's own 10-minute JWKS
cache bounds its staleness; the mirror's is the operator's to manage.
Corrects:
- spiffe_bundle_endpoint is now REJECTED at write time, not "accepted but
inactive".
- TokenReview has no mirror equivalent — it is a live call, so a cluster
with no reachable API endpoint should leave it off rather than look for
a workaround. Offline JWKS validation still authenticates the workload.
- kubernetes_api_server_url is security-sensitive: the configured host
receives Authorizer's own ServiceAccount token, so it is a trusted-host
field and the ClusterRole should stay system:auth-delegator.
* docs: correct the jti claim in the replay rule
The replay row said Kubernetes SA tokens carry no jti and used that to
justify the (iss, sub, iat, exp) fallback. Verified against a live
cluster: a projected token's claims are aud, exp, iat, iss, jti,
kubernetes.io, nbf, sub — it does carry one, and keys on it.
The fallback is still correct to document: RFC 7523 permits an issuer to
omit jti, and without it such an assertion would have no single-use key.
Just not for the reason given.
* docs: key the cluster matrix on the right variables
The matrix keyed every column on the issuer, which is only correct for
one of them. Two consequences, both in the default-issuer row:
- 'Key fetch ❌' and 'TokenReview ❌' used the same mark for two
different things. Key fetch has a remedy — the very next column said
'mirror the JWKS' — while TokenReview has none. Same symbol, opposite
meanings, in one row.
- 'TokenReview ❌ private address' does not follow from the issuer at
all. A kubeadm cluster on the default issuer can have a publicly
reachable control plane, and then TokenReview works. The row directly
above already said 'depends on the endpoint' for exactly this reason,
so the table contradicted itself.
Restructured so each column names the address that decides it: the
issuer for oidc_discovery, any reachable copy of the JWKS for
static_jwks_url, the apiserver for TokenReview. They are independent on
the same cluster.
Also scopes the mirror section, which presented a mirror as the answer
for default-issuer clusters generally. If the apiserver is reachable,
jwks_url can point straight at it and no mirror is needed.
* docs: explain why the default issuer is unreachable, both ways
The page said the default issuer is 'unresolvable outside the cluster',
which is only half of it and hides the fact that an operator sees two
different errors.
kubernetes.default.svc.cluster.local is a cluster-internal DNS record
served by CoreDNS to pods; .cluster.local is not a public zone. So from
outside it is NXDOMAIN — 'failed to resolve host'. From inside it
resolves fine, to the kubernetes Service ClusterIP (10.96.0.1 on a
default --service-cluster-ip-range), which is RFC 1918 and refused by the
SSRF guard — 'requests to private/internal networks are not allowed'.
Both verified against a live cluster.
Copy file name to clipboardExpand all lines: docs/enterprise/workload-identity.md
+81-5Lines changed: 81 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -49,15 +49,17 @@ Fields:
49
49
| Field | Notes |
50
50
|-------|-------|
51
51
|`service_account_id`| Internal `id` of the `service_account` client this issuer authenticates |
52
-
|`issuer_url`| Must equal the assertion's `iss` claim exactly. Globally unique across all trusted issuers (including per-org SSO connections) |
52
+
|`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](#clusters-on-the-default-issuer)|
53
53
|`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) |
54
54
|`jwks_url`| Required for `static_jwks_url`|
55
55
|`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 |
56
56
|`subject_claim`| Claim that identifies the workload; defaults to `sub`|
57
57
|`allowed_subjects`| Comma-separated **exact-match** subject allow-list. **Empty = deny-all** — a row with no subjects authenticates nobody |
> JWKS/discovery fetches use an SSRF-hardened HTTP client — host-pinned, redirects refused, response size capped, and private/loopback addresses rejected. The issuer's key endpoint must therefore be reachable at a publicly-routable address. The `spiffe_bundle_endpoint` key source (and its `spiffe_refresh_hint_seconds`) is accepted in the API but its fetcher is not active yet — use `oidc_discovery` or `static_jwks_url` for SPIFFE issuers today.
60
+
> 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](#kubernetes-serviceaccount-tokens).
61
+
>
62
+
> `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.
61
63
62
64
## Validation rules
63
65
@@ -71,7 +73,7 @@ Every check is fail-closed, and every rejection returns the same generic `invali
71
73
| Audience |`aud` must contain the row's `expected_aud` exactly |
72
74
| Lifetime |`exp` and `iat` required; declared lifetime (`exp − iat`) must be ≤ 1 hour; `exp`/`nbf`/`iat` checked with 60 s clock skew |
73
75
| Subject |`subject_claim` value must exactly match an `allowed_subjects` entry (never prefix/substring); empty list is deny-all |
74
-
| Replay | Assertions are **single-use** — keyed by `jti`, or by `(iss, sub, iat, exp)` when `jti` is absent (K8s SA tokens carry none), held until the token's `exp`|
76
+
| Replay | Assertions are **single-use** — keyed by `jti`, or by `(iss, sub, iat, exp)` when the issuer omits one ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523) permits it), held until the token's `exp`. Kubernetes projected ServiceAccount tokens **do** carry a `jti` and key on it|
75
77
| Type match |`jwt-bearer` assertions only match non-SPIFFE rows; `jwt-spiffe` only matches `spiffe_jwt` rows |
76
78
| Bound client | Must exist, be active, and be a `service_account`|
77
79
@@ -81,9 +83,82 @@ Because assertions are single-use, **mint a fresh platform token per token-endpo
81
83
82
84
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>`.
83
85
86
+
### Does my cluster work out of the box?
87
+
88
+
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:
89
+
90
+
```bash
91
+
kubectl get --raw /.well-known/openid-configuration | jq -r '.issuer, .jwks_uri'
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*.
|**Decided by**| is the **issuer URL** reachable? | is **any copy of the JWKS** reachable? | is the **apiserver** reachable? |
100
+
|**EKS / GKE / AKS** (public issuer, e.g. `https://oidc.eks.<region>.amazonaws.com/id/…`) | ✅ | ✅ not needed | ✅ with a public API endpoint |
101
+
|**Self-managed, custom public issuer** (`--service-account-issuer=https://…`) | ✅ | ✅ not needed | depends on the endpoint |
102
+
|**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](#clusters-on-the-default-issuer)| depends on the endpoint |
103
+
|**Any cluster, private API endpoint only**| per the issuer, above | per the JWKS, above | ❌ and there is no workaround — leave `enable_token_review` off |
104
+
105
+
Two things worth reading off that table, because they are easy to get backwards:
106
+
107
+
-**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.
108
+
-**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.
109
+
110
+
If your issuer is an `https://` URL on a public domain, the first two rows apply and registration is a two-field job.
111
+
112
+
### Clusters on the default issuer
113
+
114
+
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.
115
+
116
+
It fails for a different reason depending on where Authorizer runs, which is worth knowing because the error text differs:
117
+
118
+
| Authorizer runs | What happens | Error you see |
119
+
|---|---|---|
120
+
| Outside the cluster | The name does not resolve at all (`NXDOMAIN`) |`failed to resolve host`|
121
+
| 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`|
122
+
123
+
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.
124
+
125
+
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.
126
+
127
+
```bash
128
+
# 1. Export the cluster's PUBLIC keys. Nothing secret is in this document.
129
+
kubectl get --raw /openid/v1/jwks > jwks.json
130
+
131
+
# 2. Host it anywhere publicly reachable — object storage, your CDN, a static host.
:::warning Refresh the mirror when the cluster rotates its keys
154
+
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).
155
+
156
+
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.
157
+
:::
158
+
84
159
### 1. Register the trusted issuer
85
160
86
-
Find the cluster issuer with `kubectl get --raw /.well-known/openid-configuration | jq -r .issuer` (on managed clusters — EKS/GKE/AKS — this is a public URL and `oidc_discovery`works; for a private cluster expose the JWKS and use `static_jwks_url`):
161
+
For a cluster with a public issuer (the common case), `oidc_discovery`needs no JWKS handling at all:
87
162
88
163
```graphql
89
164
mutation {
@@ -153,7 +228,8 @@ mutation {
153
228
```
154
229
155
230
- Authorizer authenticates the TokenReview call with its **own** in-cluster ServiceAccount token, which needs the `system:auth-delegator` ClusterRole.
156
-
- The apiserver URL goes through the same SSRF-hardened client, so only a **publicly-routable apiserver endpoint** (e.g. a managed cluster's public API endpoint) works today — `https://kubernetes.default.svc` (a private ClusterIP) is rejected by design.
231
+
- 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.
232
+
-`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.
0 commit comments