Skip to content

Use GitHub Actions without stored keys

Let CI jobs act as a bot with their OIDC token, with no API key stored in GitHub, GitLab or any other CI system.

Updated View as Markdown

A CI job that can get an OpenID Connect (OIDC) ID token, such as a GitHub Actions or GitLab CI job, can exchange it for a short-lived API key that acts as a bot. Nothing secret is stored in the CI system: the job proves who it is with a token its CI provider issues for each run, and the bot’s OIDC trust decides which jobs may act as it.

You set this up once, signed in:

  1. Register your CI provider as an OIDC provider of your organization.
  2. Add OIDC trust entries to a bot: which repositories, branches or environments may act as it.
  3. In the job, exchange the token for an API key and use the key as usual.
Limit Value
OIDC providers per organization 50
OIDC trust entries per bot 20
Keys from OIDC tokens a bot holds at once 500
Key lifetime 15 minutes to 12 hours; 1 hour by default

Register an OIDC provider

An OIDC provider is identified by its issuer, compared exactly with the iss of the tokens it issues, and accepts tokens for one of its audiences (1 to 10). The issuer can’t change. When you register a provider, EZGH Cloud reads its discovery document (<issuer>/.well-known/openid-configuration), and refuses an issuer it can’t read.

Managing providers needs oidcProviders.create, oidcProviders.list, oidcProviders.get, oidcProviders.update and oidcProviders.delete.

CI provider Issuer
GitHub Actions https://token.actions.githubusercontent.com
GitLab.com https://gitlab.com
GitHub Enterprise Server, self-managed GitLab, Kubernetes Your instance’s or cluster’s issuer URL
ezgh iam oidc-providers create github --issuer https://token.actions.githubusercontent.com

The audience defaults to ezghcloud.com, the audience ezgh auth oidc-exchange asks GitHub for. Use --audiences to accept others.

curl https://orgs.ezghcloud.com/v1/organizations/$ORG_ID/oidc-providers \
  -H "Authorization: Bearer $EZGH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "github", "issuer": "https://token.actions.githubusercontent.com", "audiences": ["ezghcloud.com"]}'

Deleting a provider deletes every bot’s trust entries for it, and the keys they issued stop working at once.

Add OIDC trust to a bot

An OIDC trust entry names a provider, a subject pattern and, optionally, claims. A token matches when it comes from that provider, its sub matches the subject, and each claim equals the token’s claim exactly. In the subject, * matches any run of characters; everything else matches itself, and the whole sub must match.

Seeing a bot’s OIDC trust needs bots.get; changing it needs bots.updateTrust on the bot. Whoever can change it can let a CI job act as the bot, so treat it like bots.createApiKey.

GitHub Actions’ sub names the repository and what the job runs for, in one of two formats. GitHub decides which one your organization’s tokens use. Usually it’s repo:<owner>/<repo>:<context>; with IDs it’s repo:<owner>@<owner ID>/<repo>@<repo ID>:<context>:

The job runs for Its sub Or, with IDs
A branch repo:acme/app:ref:refs/heads/main repo:acme@1342004/app@804930151:ref:refs/heads/main
A tag repo:acme/app:ref:refs/tags/v1.2.0 repo:acme@1342004/app@804930151:ref:refs/tags/v1.2.0
An environment repo:acme/app:environment:production repo:acme@1342004/app@804930151:environment:production
A pull request repo:acme/app:pull_request repo:acme@1342004/app@804930151:pull_request

The entry that fits both formats, and keeps working when the repository or account is renamed, is repo:*:<context> with the repository’s ID pinned as a claim. Get the ID with gh api repos/acme/app --jq .id, or from the repository’s API page.

# The main branch of acme/app (its ID: gh api repos/acme/app --jq .id).
ezgh iam bots oidc-trust add deployer --provider github --subject 'repo:*:ref:refs/heads/main' --claim repository_id=804930151

# Jobs that deploy to acme/app's production environment.
ezgh iam bots oidc-trust add deployer --provider github --subject 'repo:*:environment:production' --claim repository_id=804930151

# Any repository of the acme account, pinned by the account's ID (gh api users/acme --jq .id).
ezgh iam bots oidc-trust add deployer --provider github --subject 'repo:*' --claim repository_owner_id=1342004

# Or name the repository in the subject, when your tokens use the plain format.
ezgh iam bots oidc-trust add deployer --provider github --subject 'repo:acme/app:ref:refs/heads/main'

ezgh iam bots oidc-trust list deployer
ezgh iam bots oidc-trust remove deployer <entry-id>
curl https://orgs.ezghcloud.com/v1/organizations/$ORG_ID/bots/$BOT_ID/oidc-trust \
  -H "Authorization: Bearer $EZGH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"providerId": "'"$PROVIDER_ID"'", "subject": "repo:*:ref:refs/heads/main", "claims": {"repository_id": "804930151"}}'

GET on the same URL lists the entries, and DELETE …/oidc-trust/<entry-id> removes one.

Rules for entries:

  • The subject is 1 to 256 characters, with at least one character before its first *.
  • For GitHub Actions, the subject must start with repo:<owner>/, with no * before that /, unless a claim pins repository_owner_id or repository_id. A subject like repo:* would trust every repository on GitHub.
  • At most 10 claims. Claim names are 1 to 64 letters, digits, _, . and -, and can’t be iss, sub, aud, exp, nbf, iat or jti. A number or boolean claim in the token is compared as its JSON text, such as 42 or true.
  • Prefer claims that survive renames: GitHub’s repository_id and repository_owner_id, GitLab’s project_id and namespace_id.

Removing an entry ends the keys it issued at once.

Use it in GitHub Actions

The job needs permissions: id-token: write to get a token. The ezgamehost/setup-ezgh action installs ezgh and does the exchange; later steps get the key, masked, as EZGH_API_KEY, with EZGH_ORG:

name: Deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: ezgamehost/setup-ezgh@v1
        with:
          org: org_k3f9a0x2m7qp
          bot: bot_8k2l4m6n8p0q
      - run: ezgh projects list

Pin it to a release tag, or a commit SHA for the strictest supply-chain policy.

Without the action

ezgh auth oidc-exchange needs no other credential: in GitHub Actions it gets the token from GitHub itself, and prints the key alone on standard output. Give the organization and the bot by ID or slug.

name: Deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      EZGH_ORG: org_k3f9a0x2m7qp
    steps:
      - name: Install ezgh
        run: |
          curl -fsSL https://get.ezghcloud.com | EZGH_INSTALL_DIR="$RUNNER_TEMP/bin" sh
          echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
      - name: Act as the deployer bot
        run: |
          key=$(ezgh auth oidc-exchange --org "$EZGH_ORG" --bot bot_8k2l4m6n8p0q)
          echo "::add-mask::$key"
          echo "EZGH_API_KEY=$key" >> "$GITHUB_ENV"
      - run: ezgh projects list

The key is an ordinary API key: send it as Authorization: Bearer to any EZGH Cloud API. Use --expires-in (15m to 12h) for jobs that run longer than an hour.

Use it in other CI systems

Give the token with --token-env or --token-file (- for standard input). In GitLab CI, request a token for the audience your provider accepts:

deploy:
  id_tokens:
    EZGH_ID_TOKEN:
      aud: ezghcloud.com
  script:
    - curl -fsSL https://get.ezghcloud.com | sh
    - export EZGH_API_KEY=$(ezgh auth oidc-exchange --org org_k3f9a0x2m7qp --bot bot_8k2l4m6n8p0q --token-env EZGH_ID_TOKEN)
    - ezgh projects list

Without the CLI, send the token to the exchange; it takes no other credential:

curl https://orgs.ezghcloud.com/v1/organizations/$ORG_ID/bots/$BOT_ID/oidc-api-keys \
  -H "Content-Type: application/json" \
  -d '{"token": "'"$ID_TOKEN"'", "expiresInSeconds": 3600}'

It answers 201 with { "apiKey": {…}, "token": "ezgh_…" }. expiresInSeconds is 900 to 43,200; name (1 to 100 characters) defaults to OIDC: <sub>.

Keys from OIDC tokens

  • A key acts as the bot, with exactly the bot’s access, and expires when its lifetime ends.
  • It stops working at once when its trust entry or OIDC provider is deleted, the bot is deleted, or the key is revoked.
  • It’s listed with the bot’s API keys; its sourceIdentity names the OIDC provider and the token’s sub. Revoke it like any key.
  • In Trails, the exchange is CreateOidcApiKey, and everything done with the key is the bot’s, with the token’s sub as the source identity.

Troubleshooting

Every refused exchange gets the same 401 unauthenticated, “The OIDC token wasn’t accepted”, so it doesn’t reveal which organizations, bots or providers exist. Refused exchanges aren’t recorded in Trails. Check, in order:

  1. The organization and bot are right: IDs or slugs, not names.
  2. The token’s iss is exactly one of your OIDC providers’ issuers (ezgh iam oidc-providers list), and its aud is one of that provider’s audiences.
  3. The token hasn’t expired. GitHub’s tokens last a few minutes: get one right before the exchange.
  4. One of the bot’s entries (ezgh iam bots oidc-trust list <bot>) matches the token’s sub and every claim it lists. Compare it with the sub ezgh prints: a subject like repo:acme/app:… never matches a token whose sub has IDs (repo:acme@1342004/app@804930151:…). Use repo:*:<context> with repository_id pinned, which matches either.

ezgh auth oidc-exchange prints the token’s iss, aud, sub and the claims entries usually pin when the exchange is refused, so you can compare them with the bot’s entries. 429 means too many exchanges; retry after the Retry-After seconds. 503 means EZGH Cloud couldn’t read your provider’s keys; retry shortly. 409 limit_exceeded means the bot already holds 500 keys from OIDC tokens; they expire on their own, or revoke some.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close