---
title: "Use GitHub Actions without stored keys"
description: "Let CI jobs act as a bot with their OIDC token, with no API key stored in GitHub, GitLab or any other CI system."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.ezghcloud.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Use GitHub Actions without stored keys

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](/iam/api-keys) that acts as a [bot](/iam/bots). 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 |

### CLI

```sh
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.
### API

```sh
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.

### CLI

```sh
# 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>
```
### API

```sh
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`](https://github.com/ezgamehost/setup-ezgh) action installs `ezgh` and does the exchange; later steps get the key, masked, as `EZGH_API_KEY`, with `EZGH_ORG`:

```yaml
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.

```yaml
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:

```yaml
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:

```sh
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](/iam/api-keys); its `sourceIdentity` names the OIDC provider and the token's `sub`. Revoke it like any key.
- In [Trails](/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.

Source: https://docs.ezghcloud.com/iam/github-actions/index.mdx
