Integrations
One recipe per identity provider: what its tokens contain, the claim mapping
that turns them into a Kubernetes identity, and the RBAC that binds it. Every
recipe is one entry for the jwt: list of an
AuthenticationConfiguration; combine them freely in a
single configuration, and give every issuer its own distinct username and
groups prefix (see
security: distinct per-issuer prefixes).
The same three steps apply to every provider:
- Decode a real token before writing anything; the claims are the raw material and their names are not always what they suggest.
- Map a username, groups and, for audit,
extravalues; validate what must be true of every token, pinning numeric IDs rather than names wherever the provider has them. - Bind roles to the groups the mapping produces, then verify with
kubectl auth whoamiand one allowed and one denied action, as in getting started.
- GitHub Actions
- TeamCity (teamcity-oidc-jwt plugin)
- Google service accounts (workloads on GKE / GCE)
- GKE workloads via the cluster’s own issuer (per-ServiceAccount identity)
- Internal / custom issuer with a private CA
- GitLab CI
- RBAC for the mapped identities
- See also
GitHub Actions
Section titled “GitHub Actions”GitHub mints an OIDC token for a workflow job on request
(permissions: id-token: write, then core.getIDToken('<audience>') in
actions/github-script, or the equivalent in your action of choice). The
audience you pass must be one of the issuer’s audiences below (several are
allowed with audienceMatchPolicy: MatchAny, see
multi-issuer). Tokens
live about five minutes.
What a token contains
Section titled “What a token contains”Decode one before writing mappings; the claims are the raw material and a few
of them are not what their names suggest. A workflow_dispatch run on main
of my-org/my-repo carries:
{ "iss": "https://token.actions.githubusercontent.com", "aud": "kube-oidc-proxy.example.com", "sub": "repo:my-org/my-repo:ref:refs/heads/main",
"repository": "my-org/my-repo", "repository_id": "7654321", "repository_owner": "my-org", "repository_owner_id": "1234567", "repository_visibility": "internal", "enterprise": "my-enterprise", "enterprise_id": "2468",
"ref": "refs/heads/main", "ref_type": "branch", "ref_protected": "false", "base_ref": "", "head_ref": "", "sha": "bcdb9add7ec52edf83c3a2df6869187302d2fc7b",
"workflow": "deploy", "workflow_ref": "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main", "workflow_sha": "bcdb9add7ec52edf83c3a2df6869187302d2fc7b", "job_workflow_ref": "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main", "job_workflow_sha": "bcdb9add7ec52edf83c3a2df6869187302d2fc7b",
"event_name": "workflow_dispatch", "actor": "octocat", "actor_id": "555", "run_id": "33964329992", "run_number": "1", "run_attempt": "1", "runner_environment": "github-hosted", "check_run_id": "101301612960", "jti": "f2f7364b-09a2-4422-8ef4-7b8e6c135597", "iat": 1788608938, "nbf": 1788608638, "exp": 1788609238}Things to notice:
- There is no groups claim. Every group has to be synthesized with CEL.
- Every GitHub-specific value is a string, including
ref_protected: "false"and every numeric ID; only the standardiat,nbfandexpare numbers. Compare against"true"in quotes; a bare boolean never matches. - Names can be recycled, IDs cannot.
repository_owner_id,repository_idandenterprise_idare the values to pin in validation rules; the names are for reading. subis not a stable format. Newer GitHub tokens embed numeric IDs in it (repo:Owner@<owner_id>/repo@<repo_id>:ref:...), so a username derived fromsubcan change under existing bindings. Build the username fromrepositoryandrefinstead.environmentis present only when the job declares one, andenterprise/enterprise_idonly on GitHub Enterprise Cloud. Referencing an absent claim makes the whole expression error and the token is rejected, so guard optional claims withhas().- Two workflow refs.
job_workflow_refnames the workflow file actually running the job, which for a reusable workflow is the called one;workflow_refis the top-level caller. Pinjob_workflow_refwhen the trust lives in a central reusable workflow.
Minimal mapping
Section titled “Minimal mapping”A readable username, one group for the owning organisation, and the owner ID pinned. Enough for a first binding; the next section is what to grow it into.
- issuer: url: https://token.actions.githubusercontent.com audiences: ["kube-oidc-proxy.example.com"] claimMappings: username: expression: '"gha:" + claims.repository + ":" + claims.ref' groups: expression: '["gha:org:" + claims.repository_owner]' claimValidationRules: - expression: 'claims.repository_owner_id == "1234567"' message: "token not issued for the expected organisation"The token above authenticates as user gha:my-org/my-repo:refs/heads/main in
group gha:org:my-org. Prefixes must live inside the expression: the prefix:
field only applies to the claim: form, and a shared gha: root keeps every
group this issuer produces in one namespace.
Production mapping: trust tiers, audit extras, hard gates
Section titled “Production mapping: trust tiers, audit extras, hard gates”The recipes above show the mechanics. This one is a complete issuer entry for an organisation that lets many repositories deploy through one proxy. It is built on three ideas:
- RBAC ORs its subjects. A binding can list several groups, but any one
of them is enough. There is no way to say “repo X and a protected
branch”. So every group must already encode its whole trust condition:
gha:protected:my-org/my-repo, not a baregha:protectedthat any repo could satisfy. - Groups are for RBAC;
extrais for audit. Things you would never bind a role to (the triggering actor, the run ID, the commit SHA) still belong in the audit log.claimMappings.extraputs them intouser.extraof every API-server audit event without growing the group list. - Pin numeric IDs, print names. GitHub names (org, repo, actor) can be renamed or re-registered; the numeric IDs cannot. Validation rules pin the IDs. Name-based groups stay for readability, ID-based ones for bindings that must survive a rename.
- issuer: url: https://token.actions.githubusercontent.com audiences: ["kube-oidc-proxy.example.com"] claimMappings: username: # Audit identity: gha:<org>/<repo>:<ref>. Bind RBAC to groups, not to # this, except for one-off pinpoint grants. expression: '"gha:" + claims.repository + ":" + claims.ref' groups: # Base groups every token gets, then conditional tiers a token only # gets when it earned them. `cond ? [x] : []` adds x or nothing. expression: >- [ "gha:org:" + claims.repository_owner, "gha:org-id:" + claims.repository_owner_id, "gha:repo:" + claims.repository, "gha:workflow:" + claims.job_workflow_ref ] + (claims.ref_protected == "true" ? ["gha:protected:" + claims.repository] : []) + (claims.ref_type == "tag" ? ["gha:tag:" + claims.repository] : []) + (has(claims.environment) ? ["gha:env:" + claims.repository + ":" + claims.environment] : []) extra: # Forwarded as Impersonate-Extra-* headers; visible in audit logs only. - key: github.com/actor valueExpression: claims.actor - key: github.com/actor-id valueExpression: claims.actor_id - key: github.com/event valueExpression: claims.event_name - key: github.com/run-id valueExpression: claims.run_id - key: github.com/run-attempt valueExpression: claims.run_attempt - key: github.com/sha valueExpression: claims.sha - key: github.com/repository valueExpression: claims.repository - key: github.com/repository-id valueExpression: claims.repository_id - key: github.com/org-id valueExpression: claims.repository_owner_id - key: github.com/runner valueExpression: claims.runner_environment claimValidationRules: # Hard gates: a token failing any of these is rejected before mapping. - expression: 'claims.enterprise == "my-enterprise"' message: "token not issued within the expected enterprise" - expression: 'claims.enterprise_id == "2468"' message: "token not issued within the expected enterprise (id mismatch)" - expression: 'claims.repository_owner_id == "1234567"' message: "token not issued for the expected organisation" - expression: 'claims.repository_visibility != "public"' message: "tokens from public repositories are not accepted" # Optional: accept only your own runners. # - expression: 'claims.runner_environment == "self-hosted"' # message: "only self-hosted runners may reach this cluster" userValidationRules: - expression: "!user.username.startsWith('system:')" message: "username cannot use the system: prefix" - expression: "user.groups.all(g, !g.startsWith('system:'))" message: "groups cannot use the system: prefix"The enterprise and enterprise_id claims exist only on GitHub Enterprise
Cloud tokens; drop those two rules on a plain github.com organisation, or the
missing claim makes the rule error and every token is rejected.
What a token maps to. For a workflow_dispatch run on the unprotected
main branch of my-org/my-repo, triggered by octocat, with no
environment: in the job:
| username | gha:my-org/my-repo:refs/heads/main |
| groups | gha:org:my-org |
gha:org-id:1234567 | |
gha:repo:my-org/my-repo | |
gha:workflow:my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main | |
| extra | github.com/actor=octocat, github.com/actor-id=555, github.com/event=workflow_dispatch, github.com/run-id=…, github.com/sha=…, … |
A push from a protected branch into a job with environment: prod, calling a
central reusable workflow, additionally yields gha:protected:my-org/my-repo
and gha:env:my-org/my-repo:prod, and the workflow group becomes the reusable
workflow’s own ref (e.g. …/platform-workflows/.github/workflows/deploy.yml@refs/tags/v3).
A release built from a tag yields gha:tag:my-org/my-repo instead.
Which tier to bind to what.
| Group | Means | Bind it to |
|---|---|---|
gha:org:<org> / gha:org-id:<id> | any repo in the org, any ref | read-only roles only (view) |
gha:repo:<org>/<repo> | any ref of one repo | that repo’s own namespace, edit or a purpose-built role |
gha:protected:<org>/<repo> | ref is under a branch/tag protection rule or ruleset | deploy roles for shared or production namespaces |
gha:env:<org>/<repo>:<env> | job passed that environment’s protection rules (reviewers, wait timer) | production deploys; strongest signal available |
gha:tag:<org>/<repo> | ref is a tag | release pipelines that cut from tags |
gha:workflow:<ref> | exact workflow file at exact ref | a platform-owned reusable deploy workflow: only jobs calling it get the role, whichever repo they live in |
The environment group is repo-scoped on purpose: environment names are per
repository, so an org-wide gha:env:prod would admit every repo’s prod.
apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: name: gha-prod-deployers namespace: paymentssubjects:- kind: Group name: "gha:env:my-org/payments:prod" # passed the prod environment gate apiGroup: rbac.authorization.k8s.io- kind: Group name: "gha:workflow:my-org/platform-workflows/.github/workflows/deploy.yml@refs/tags/v3" apiGroup: rbac.authorization.k8s.ioroleRef: kind: ClusterRole name: edit apiGroup: rbac.authorization.k8s.ioNotes:
- The three conditional terms compare strings: GitHub emits
"ref_protected": "false"as text, so== "true"in quotes is correct and a bare== truewould never match. has(claims.environment)is mandatory. Without the guard, every token that lacks the claim makes the expression error and is rejected.- Claims left out on purpose:
jti,run_number,check_run_id,workflow_shaandjob_workflow_shaadd nothing overrun_idandsha;base_ref/head_refare empty outside pull requests.subis not used for the username because GitHub is changing its format to embed numeric IDs, which would silently change every binding. - Each group is bound in RBAC by its exact string, colons and slashes included; the whole value is one opaque group name, not a hierarchy Kubernetes interprets.
gha:workflow:is high-cardinality: prefer it for narrow, per-workflow bindings and use the org or repo tiers for broad access.
TeamCity (teamcity-oidc-jwt plugin)
Section titled “TeamCity (teamcity-oidc-jwt plugin)”The plugin serves unauthenticated discovery/JWKS under
<server>/app/oidc-jwt/.well-known/.... Its raw sub is an internal ID
chain (_Root:project31:bt32); the external-ID claims make far better
identities:
- issuer: url: https://teamcity.example.com/app/oidc-jwt audiences: ["kube-oidc-proxy.example.com"] # certificateAuthority: | # inline CA if TeamCity uses a private cert # -----BEGIN CERTIFICATE----- # ... # -----END CERTIFICATE----- claimMappings: username: # one identity per build configuration, e.g. "tc:MyProject_Deploy" expression: '"tc:" + claims.build_type_external_id' groups: # bind whole TeamCity projects at once expression: '["tc-project:" + claims.project_external_id]' claimValidationRules: # only default-branch builds may authenticate (drop for branch builds): - expression: 'claims.branch_is_default == true' message: "only default-branch TeamCity builds may authenticate"Build side: enable the plugin’s Build Parameters feature with the audience
configured — the token arrives as env.TEAMCITY_BUILD_OIDC_TOKEN; or fetch
on demand from %teamcity.serverUrl%/app/oidc-jwt/issue?aud=<audience>.
Google service accounts (workloads on GKE / GCE)
Section titled “Google service accounts (workloads on GKE / GCE)”Google-signed ID tokens from accounts.google.com; any workload running as
a Google service account (including GKE pods via Workload Identity) can mint
one from the metadata server.
- issuer: url: https://accounts.google.com audiences: ["kube-oidc-proxy.example.com"] claimMappings: username: # → "gcp:deployer@my-project.iam.gserviceaccount.com" expression: '"gcp:" + claims.email' groups: # group per GCP project, derived from the SA email domain expression: '["gcp-project:" + claims.email.split("@")[1].split(".")[0]]' claimValidationRules: # required: with an expression-based username the authenticator does NOT # auto-enforce email_verified (that only happens for `claim: email`) - expression: 'claims.email_verified == true' message: "unverified email claim"Workload side (format=full is what includes the email claim):
TOKEN=$(curl -sH "Metadata-Flavor: Google" \ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=kube-oidc-proxy.example.com&format=full")GKE workloads via the cluster’s own issuer (per-ServiceAccount identity)
Section titled “GKE workloads via the cluster’s own issuer (per-ServiceAccount identity)”Alternatively, trust a GKE cluster itself as an issuer — every Kubernetes ServiceAccount in it becomes a bindable identity, with no Google service accounts involved. GKE publishes public OIDC discovery for its ServiceAccount issuer. Use one entry (and one prefix) per source cluster.
- issuer: url: https://container.googleapis.com/v1/projects/MY_PROJECT/locations/MY_LOCATION/clusters/MY_CLUSTER audiences: ["kube-oidc-proxy.example.com"] claimMappings: username: # sub is "system:serviceaccount:<ns>:<sa>"; the prefix makes it # collision-proof: → "gke-prod:system:serviceaccount:payments:deployer" claim: sub prefix: "gke-prod:" groups: # group per source namespace expression: '["gke-prod-ns:" + claims["kubernetes.io"].namespace]'Workload side — a projected token with the right audience, read from the mounted path and used as the bearer token:
volumes:- name: cluster-access-token projected: sources: - serviceAccountToken: audience: kube-oidc-proxy.example.com expirationSeconds: 600 path: tokenInternal / custom issuer with a private CA
Section titled “Internal / custom issuer with a private CA”Systems you control should emit a real groups array — then no CEL is needed and onboarding new workloads is a change in the issuing system, not in cluster RBAC:
- issuer: url: https://auth.internal.example.com audiences: ["kubernetes"] certificateAuthority: | -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE----- claimMappings: username: {claim: sub, prefix: "sys-a:"} groups: {claim: groups, prefix: "sys-a:"}GitLab CI
Section titled “GitLab CI”- issuer: url: https://gitlab.example.com audiences: ["kube-oidc-proxy.example.com"] claimMappings: username: expression: '"gitlab:" + claims.project_path' groups: expression: '["gitlab:ns:" + claims.namespace_path]'RBAC for the mapped identities
Section titled “RBAC for the mapped identities”Bindings reference the prefixed usernames and groups exactly as the mappings produce them:
apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata: name: ci-deployerssubjects:- kind: User name: "tc:MyProject_Deploy" # a single TeamCity build config apiGroup: rbac.authorization.k8s.io- kind: Group name: "gke-prod-ns:payments" # every SA in one GKE namespace apiGroup: rbac.authorization.k8s.io- kind: User name: "gha:my-org/platform-iac:refs/heads/main" # one repo, main branch only apiGroup: rbac.authorization.k8s.ioroleRef: kind: ClusterRole name: edit # prefer purpose-built roles apiGroup: rbac.authorization.k8s.ioSee also
Section titled “See also”- Authentication and identity — choosing a configuration, the impersonation model, glossary.
- Multi-issuer authentication — the configuration format these recipes plug into, readiness and Helm wiring.
- Auditing — what the
extravalues mapped here look like in the audit trail.