Federate a k8s workload to hort-server via projected SA tokens
This guide is for platform engineers running Flux (or any other gitops controller) who want their k8s workloads to authenticate to hort without a long-lived PAT pasted into a Secret. The workload's pod fetches a projected ServiceAccount token from the cluster's API server, exchanges it at POST /api/v1/auth/exchange, and gets back a short-lived bearer whose cap snapshots the matched kind: ServiceAccount's PermissionGrants at exchange time.
For the design rationale see ADR 0018 and the federation entries in docs/auth-catalog.md.
1. What this gives you
The 2026 industry-standard pattern for non-human identities in k8s is "no long-lived tokens at rest." Every pod that needs to talk to hort-server fetches a short-lived JWT from its own cluster's API server, exchanges it for an hort-server bearer, and either repeats the exchange when the bearer expires or simply re-fetches the projected token on each call. Nothing persists across pod restarts.
hort-server treats the cluster as a generic OIDC issuer: the JWKS endpoint is the cluster's /openid/v1/jwks (served by the API server itself when --service-account-issuer is configured), the JWT's iss claim is the cluster's issuer URL, and the sub claim encodes the pod's ServiceAccount as system:serviceaccount:<namespace>:<name>. The operator declares trust by writing one kind: OidcIssuer envelope per cluster + one kind: ServiceAccount envelope per workload identity.
The two artefacts that change in your gitops repository:
kind: OidcIssuer— names the trusted cluster and binds the audience the projected token must carry.kind: ServiceAccount— declares the non-human identity in hort-server and lists which JWT claim shapes are allowed to assume it. Its authority is thePermissionGrants declared alongside (ADR 0044).
Existing PATs continue to work — federation is additive. Operators adopt this path workload-by-workload at their own pace.
2. Prerequisites
- k8s 1.21+ on every cluster that participates. Projected ServiceAccount tokens reached GA in 1.21; earlier versions issue only legacy long-lived SA tokens, which are out of scope here.
- The cluster's API server is started with
--service-account-issuer=<URL>(a stable, externally reachable URL) and--service-account-jwks-uri=<URL>(or relies on the default/.well-known/openid-configuration). Managed offerings (GKE, EKS, AKS) expose this out of the box; self-hosted k8s installations using kubeadm pass the flags inkube-apiserverstatic-pod manifests. - hort-server can reach the cluster's JWKS endpoint over HTTPS. If the cluster uses a private CA, mount the CA bundle on hort-server via
HORT_EXTRA_CA_BUNDLE— there is noinsecure_jwks_urlknob (ADR 0010). - Operator authority to
kubectl apply -fin the workload's namespace, and gitops write access for the new envelopes.
Useful upstream documentation: ServiceAccount token volume projection and Manage Service Accounts (issuer flags).
3. Step 1 — declare the OidcIssuer
One envelope per cluster. The issuerUrl must match the cluster's --service-account-issuer flag exactly — hort-server matches on the iss claim string with no normalisation.
apiVersion: project-hort.de/v1
kind: OidcIssuer
metadata:
name: cluster-prod
spec:
issuerUrl: https://kubernetes.default.svc.cluster.local
audiences: [hort-server]
jwksRefreshInterval: 1h
allowedAlgorithms: [RS256]
Field-by-field:
metadata.name— envelope identity. Used byServiceAccountenvelopes'federatedIdentities[].issuerreference. Pick a name per cluster you trust (cluster-prod,cluster-staging, …).spec.issuerUrl— exactissclaim. For most managed offerings this is an HTTPS URL; for self-hosted k8s the in-cluster default ishttps://kubernetes.default.svc.cluster.local. Plaintext (http://) is rejected at apply time.spec.audiences— the projected token'saudmust be one of these.hort-serveris the conventional choice; if multiple hort-server deployments share one cluster, scope per-deployment (e.g.hort-server-prod,hort-server-staging).spec.jwksRefreshInterval— bounded between1mand24h. The default1his appropriate for most clusters; tighten if you rotate signing keys aggressively.spec.allowedAlgorithms— k8s API servers default toRS256; symmetric algorithms (HS*) are forbidden by the domain enum because there is no JWKS to verify them against.
kubectl apply -f oidc-issuer-cluster-prod.yaml is the wrong tool — kind: OidcIssuer is an hort-server gitops envelope, not a k8s CRD. Place the file in $HORT_CONFIG_DIR and restart hort-server (or push to your gitops repo and let Flux roll the chart). See declare-gitops-config.md §5 for the boot sequence.
4. Step 2 — declare the ServiceAccount
One envelope per workload identity. The federatedIdentities[] block lists which (issuer, claims) shapes may assume this SA. Multiple shapes are an OR — any single match suffices; multiple matches across SAs are a multiple_sa_match deny.
The envelope binds the identity; the PermissionGrant(s) beside it are the SA's authority — the exchanged bearer's cap snapshots them at exchange time:
apiVersion: project-hort.de/v1
kind: ServiceAccount
metadata:
name: prod-pypi-pusher
spec:
federatedIdentities:
- issuer: cluster-prod
claims:
sub: system:serviceaccount:apps:pypi-publisher
---
apiVersion: project-hort.de/v1
kind: PermissionGrant
metadata:
name: prod-pypi-pusher-write
spec:
subject:
kind: serviceAccount
name: prod-pypi-pusher
permission: write
repository: pypi-internal
The claims: map is exact-match — every (key, value) in the map must equal the corresponding field in the JWT payload. The canonical k8s SA claim shape is sub: system:serviceaccount:<namespace>:<name>; that single claim is usually enough to pin the identity. Newer clusters also expose kubernetes.io/serviceaccount/namespace and …/name as separate claims — match on whichever subset gives you the desired specificity.
Validation rules the apply pipeline enforces:
- The envelope carries no authority fields —
role:/repositories:fail apply at parse. AserviceAccount-subject grant withpermission: adminis rejected — service accounts are strictly non-admin (ADR 0038). federatedIdentities[].issuermust reference a declaredOidcIssuer. Apply-time foreign key.federatedIdentities[].claimsnon-empty. Empty claims means "any JWT from this issuer can assume me" — a privilege-escalation footgun on a misconfigured issuer. Hard reject.
See declare-gitops-config.md kind: ServiceAccount for the canonical reference.
5. Step 3 — configure the pod
Mount a projected SA token with the right audience. The token's aud must be one of the values in OidcIssuer.spec.audiences — hort-server in this example.
apiVersion: v1
kind: Pod
metadata:
name: pypi-publisher
namespace: apps
spec:
serviceAccountName: pypi-publisher
containers:
- name: app
image: my-org/pypi-publisher:1.2.3
volumeMounts:
- name: hort-token
mountPath: /var/run/secrets/hort-server
readOnly: true
volumes:
- name: hort-token
projected:
sources:
- serviceAccountToken:
path: token
audience: hort-server
expirationSeconds: 3600
expirationSeconds: 3600 is the lower bound k8s honours; the API server may issue a longer-lived token but kubelet refreshes the file inside the pod well before expiry (kubelet starts refreshing at 80% of token lifetime). Treat the token file as a moving target — read it on every exchange, do not cache its contents in process memory.
The pod's serviceAccountName: pypi-publisher must exist in the namespace (kubectl create serviceaccount pypi-publisher -n apps). That k8s ServiceAccount is the sub claim source — the JWT will carry sub: system:serviceaccount:apps:pypi-publisher, which is exactly what the kind: ServiceAccount envelope above matches on.
6. Step 4 — fetch and exchange
Inside the pod, read the projected token and post it to /api/v1/auth/exchange. The exchange returns a short-lived bearer.
JWT=$(cat /var/run/secrets/hort-server/token)
RESPONSE=$(curl -sS -X POST \
https://hort.example.com/api/v1/auth/exchange \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=${JWT}" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:jwt")
HORT_TOKEN=$(echo "${RESPONSE}" | jq -r .access_token)
The response body is RFC 8693 §2.2.1 standard:
{
"access_token": "hort_svc_…",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600
}
expires_in is the lesser of 1 hour and the JWT's remaining exp — the bearer cannot outlive the source token. No refresh token is issued; when ${HORT_TOKEN} expires, re-read the projected token (still fresh, kubelet keeps it that way) and repeat the exchange.
7. Step 5 — verify it works
A twine upload is a tight end-to-end check: it exercises auth, authorization, repository scoping, and the ingest path. Substitute your repository's URL and any tiny package you have at hand.
twine upload \
--repository-url https://hort.example.com/pypi/pypi-internal/ \
--username __token__ \
--password "${HORT_TOKEN}" \
dist/*.whl
A successful upload returns 200 OK from twine and prints the artifact path. To confirm the federation path was actually taken, check hort-server logs for the TokenIssued event matching this exchange:
kubectl logs -n hort-server deploy/hort-server | grep TokenIssued | tail -1
Look for source_issuer = "cluster-prod", source_sub = "system:serviceaccount:apps:pypi-publisher", and source_jti populated with the projected token's jti. If those three fields appear, the federation branch handled the exchange — not a fallback path.
The metric hort_token_exchange_total{kind="federated_jwt", result="success"} increments on every successful exchange. Plot it alongside kind="federated_jwt", result!="success" to alert on broken trust policies.
8. Troubleshooting
Every deny returns 403 with a error_description body. The table maps the deny reason to its likely cause; the second column quotes the deny hint hort-server emits in the response body so operator-side automation can match exactly.
| Reason | Deny hint | Likely cause |
|---|---|---|
unknown_issuer | "no OidcIssuer matches \iss\ — declare one or fix the JWT" | The iss claim of the projected token does not equal OidcIssuer.spec.issuerUrl. Compare the JWT payload's iss to the envelope. The cluster's --service-account-issuer may have changed (e.g. after a managed-control-plane upgrade). |
aud_mismatch | "aud not in OidcIssuer.audiences" | The projected token's aud does not match any entry in spec.audiences. Common cause: the serviceAccountToken.audience: on the pod is missing or differs from hort-server. |
no_sa_match | (handler-emitted) "no ServiceAccount.federatedIdentities matched" | The token validated cryptographically but the claims: map in every candidate SA failed exact-match against the JWT payload. The pod's sub claim is the usual mismatch site — typo in the namespace or k8s SA name. |
multiple_sa_match | (handler-emitted) "multiple ServiceAccount matches" | Two ServiceAccount envelopes are overly broad. Tighten one envelope's claims: map (add a discriminator). The full SA-name candidates appear in hort-server's INFO log. |
signature_invalid | "signature verification failed" | JWKS cache is stale relative to the issuer, or the projected token was minted by a different cluster. Lowering jwksRefreshInterval to 5m temporarily, then back, is a fast way to confirm staleness. |
For the full taxonomy see the FederationDenyReason enum in crates/hort-domain/src/ports/federated_jwt_validator.rs.
9. What's NOT covered
- Cross-cluster trust. Each cluster's API server has its own signing keys and its own
issclaim. Declare onekind: OidcIssuerper cluster; do not try to share an envelope across clusters by listing bothissvalues. - Rotation of the cluster's signing key. k8s clusters rotate signing keys on their own cadence; the JWKS-refresh interval on the
OidcIssuercovers the propagation delay. If your cluster rotates keys faster than1h, drop the interval (down to1mminimum). - Long-lived
kubernetes.io/service-account-tokenSecrets. Legacy SA Secrets are not OIDC JWTs and do not flow through this path. If your workload predates projected tokens, seerotating-service-account-tokens.mdfor the fallback PAT-rotation recipe instead. - Multi-tenant scoping.
ServiceAccountenvelopes are global to the hort-server deployment. Per-namespace authorization scoping is deferred future work.
10. See also
docs/auth-catalog.md— the canonical auth-surface catalog, including the federation/exchange entries.federate-ci-oidc.md— the same pattern for GitHub Actions and GitLab CI runners.rotating-service-account-tokens.md— fallback PAT rotation for workloads that cannot do OIDC.declare-gitops-config.mdkind: OidcIssuer+kind: ServiceAccount— the canonical reference for the two envelopes shown here.using-hort-cli-with-admin-ops.md— human-CLI admin flow. Workloads use federation; humans usehort-cli auth login.