Skip to content
Development docs · last release v1.8.1 · tested on Kubernetes 1.35 to 1.37Release notes

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:

  1. Decode a real token before writing anything; the claims are the raw material and their names are not always what they suggest.
  2. Map a username, groups and, for audit, extra values; validate what must be true of every token, pinning numeric IDs rather than names wherever the provider has them.
  3. Bind roles to the groups the mapping produces, then verify with kubectl auth whoami and one allowed and one denied action, as in getting started.

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.

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 standard iat, nbf and exp are numbers. Compare against "true" in quotes; a bare boolean never matches.
  • Names can be recycled, IDs cannot. repository_owner_id, repository_id and enterprise_id are the values to pin in validation rules; the names are for reading.
  • sub is not a stable format. Newer GitHub tokens embed numeric IDs in it (repo:Owner@<owner_id>/repo@<repo_id>:ref:...), so a username derived from sub can change under existing bindings. Build the username from repository and ref instead.
  • environment is present only when the job declares one, and enterprise/enterprise_id only on GitHub Enterprise Cloud. Referencing an absent claim makes the whole expression error and the token is rejected, so guard optional claims with has().
  • Two workflow refs. job_workflow_ref names the workflow file actually running the job, which for a reusable workflow is the called one; workflow_ref is the top-level caller. Pin job_workflow_ref when the trust lives in a central reusable workflow.

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:

  1. 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 bare gha:protected that any repo could satisfy.
  2. Groups are for RBAC; extra is 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.extra puts them into user.extra of every API-server audit event without growing the group list.
  3. 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:

usernamegha:my-org/my-repo:refs/heads/main
groupsgha: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
extragithub.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.

GroupMeansBind it to
gha:org:<org> / gha:org-id:<id>any repo in the org, any refread-only roles only (view)
gha:repo:<org>/<repo>any ref of one repothat repo’s own namespace, edit or a purpose-built role
gha:protected:<org>/<repo>ref is under a branch/tag protection rule or rulesetdeploy 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 tagrelease pipelines that cut from tags
gha:workflow:<ref>exact workflow file at exact refa 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/v1
kind: RoleBinding
metadata:
name: gha-prod-deployers
namespace: payments
subjects:
- 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.io
roleRef:
kind: ClusterRole
name: edit
apiGroup: rbac.authorization.k8s.io

Notes:

  • The three conditional terms compare strings: GitHub emits "ref_protected": "false" as text, so == "true" in quotes is correct and a bare == true would 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_sha and job_workflow_sha add nothing over run_id and sha; base_ref/head_ref are empty outside pull requests. sub is 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.

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

Terminal window
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: token

Internal / 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:"}
- 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]'

Bindings reference the prefixed usernames and groups exactly as the mappings produce them:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: ci-deployers
subjects:
- 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.io
roleRef:
kind: ClusterRole
name: edit # prefer purpose-built roles
apiGroup: rbac.authorization.k8s.io