---
title: OIDC Authentication for GitHub Actions
description: Let GitHub Actions jobs reach Mergify with the ID token GitHub signs for them instead of a MERGIFY_TOKEN application key, under trust policies you write.
---

A GitHub Actions job can reach Mergify without `MERGIFY_TOKEN`. It asks GitHub for an OpenID
Connect (OIDC) ID token and trades it with Mergify for a token that works for one hour, on that
job's repository only. Your trust policies decide which jobs get one. This page takes a workflow
that stores a `ci` [application key](/api/usage#application-key-scopes) in `MERGIFY_TOKEN` to one
that stores nothing.

A `ci` key never expires, works on every repository of your organization, and sits in repository
secrets, where it can leak from a build log or a workflow change. A token from an ID token covers
one repository for an hour, and Mergify reads the job's branch from what GitHub signed rather than
from what the job reports.

## How it works

When a Mergify client finds no `MERGIFY_TOKEN` in a job granted `id-token: write`, it exchanges
the job's ID token:

1. The client asks GitHub for an ID token meant for the Mergify API, `https://api.mergify.com`.
   GitHub signs into it what the job is: its repository, workflow, event, ref, commit and
   environment.

2. Mergify checks the signature, that the token was meant for it, and that its GitHub App is
   installed on the repository. Then it looks for a trust policy of your organization that matches
   the job.

3. When one matches, Mergify answers with a token that does what a `ci` key does, on that
   repository only: upload test results and [scopes](/merge-queue/scopes), read the test runs
   recorded for a commit and the [quarantine](/test-engine/quarantine) list, and read the
   [flaky-detection](/ci-insights/flaky-test-detection) and test-selection data a test run
   fetches. Any other request is refused. The client renews the token before its hour is up.

4. When none matches, the exchange is refused and the client prints why in the job log. The job
   then runs without a token: the test framework plugins run the tests with no quarantine
   and upload nothing, `mergify ci junit-process` fails its step, and `mergify ci scopes-send`
   warns and sends no scopes.

```dot class="flow" title="A job trades its GitHub ID token for a Mergify token only when one of your trust policies matches the job: green is a token, red a refusal"
digraph {
  node [width=2.6];

  Job [label="GitHub Actions job\nwith id-token: write", class="external"];
  GitHub [label="GitHub signs an ID token:\nrepository, workflow,\nevent, environment", class="external"];
  Mergify [label="Mergify checks the\nsignature, the audience\nand the repository", class="mergify"];
  Policy [label="Does a trust policy\nmatch the job?", class="config"];
  Token [label="Token for this\nrepository,\none hour", class="merged", width=1.6];
  Refused [label="Refused:\nnothing\nuploaded", class="failed", width=1.6];

  Job -> GitHub [label="  asks for an ID token\l"];
  GitHub -> Mergify [label="  the client sends it\l"];
  Mergify -> Policy;
  Policy -> Token [label=" yes"];
  Policy -> Refused [label=" no"];
}
```

A `MERGIFY_TOKEN` that is set always wins: the client exchanges only when the variable is unset or
empty. Until you remove the key from a job, nothing changes for it, so you can write your trust
policies first.

A `pull_request` job's results are filed under the ref GitHub signed for it, its pull request's
merge ref `refs/pull/<number>/merge`, and still count for that pull request.

## Before you start

- **A client that exchanges.** The latest releases of the [test framework
  plugins](/test-engine/test-frameworks) (pytest, RSpec, Minitest, Vitest, Playwright, Jest,
  Mocha), of the [Mergify CLI](/cli/usage) (`mergify ci junit-process` and
  `mergify ci scopes-send`), and of [`mergifyio/gha-mergify-ci`](/integrations/gha).

- **GitHub Actions.** Buildkite, Jenkins and CircleCI jobs keep using a `ci` key.

## Move your workflows off `MERGIFY_TOKEN`

### Write a trust policy

Open **Settings → Developer → OIDC** on the [dashboard](https://dashboard.mergify.com).
GitHub organization `Owner`s and users holding the **CI Admin** [delegated
role](/security#delegated-roles) can write policies.

Click **Trust CI on Every Repository** to create a policy named `CI on every repository`: every
repository, any workflow, the CI events, any environment. Click **Write a Policy** instead to
start narrower. [Trust policies](#trust-policies) below describes each line.

Until your organization has a policy, every exchange is refused.

### Let the job request an ID token

Grant `id-token: write` to the job, or to the whole workflow:

```yaml
permissions:
  id-token: write # lets the job ask GitHub for an ID token
  contents: read # actions/checkout needs it
```

A `permissions` block sets every permission it does not list to `none`, so list the ones the
job's other steps need as well. `mergifyio/gha-mergify-ci` and the test framework plugins need
nothing more.

A [reusable workflow](https://docs.github.com/en/actions/using-workflows/reusing-workflows) gets
at most what its caller grants: give `id-token: write` to the calling job. A job of the called
workflow that declares its own `permissions` must list `id-token: write` too.

### Remove the key from the job

Remove `MERGIFY_TOKEN` from the step's `env`, the `token` input from `mergifyio/gha-mergify-ci`,
and `--token` from `mergify` commands. A job that runs pytest then needs nothing else:

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v5
      # Add your setup steps here (Python, Node.js, etc.)
      - name: Run tests
        run: pytest
```

The upload step of `mergifyio/gha-mergify-ci` keeps its other inputs:

```yaml
- name: Mergify CI Upload
  # A pull request from a fork gets no ID token: skip the upload there
  if: (success() || failure()) && !github.event.pull_request.head.repo.fork
  uses: mergifyio/gha-mergify-ci@v30
  with:
    report_path: junit.xml
    test_step_outcome: ${{ steps.tests.outcome }}
```

Here `tests` is the `id` of your test step. The fork condition replaces the `HAS_MERGIFY_TOKEN`
guard of [Pull requests without the
secret](/ci-insights/setup/github-actions#pull-requests-without-the-secret): once the secret is
deleted, that guard skips every upload. If your test step has `continue-on-error: true`, fail the
job when the tests failed and nothing was uploaded:

```yaml
- name: Fail when tests failed and no report was uploaded
  if: steps.tests.outcome == 'failure' && github.event.pull_request.head.repo.fork
  run: exit 1
```

A line that still reads `${{ secrets.MERGIFY_TOKEN }}` once you delete the secret gives an empty
value, and an empty `MERGIFY_TOKEN` counts as unset. Stop passing the secret to reusable
workflows too: they need `id-token: write` from their caller, not the key.

### Check the exchanges

The **Exchanges** tab of the OIDC settings lists every exchange for 90 days: the repository, the
workflow, the event and ref, and the policy that allowed it or why it was refused. On an exchange
refused because no trust policy matched, **Allow Jobs Like This** opens a new policy filled in
with that job's repository, workflow and event. Nothing is saved until you save it.

**Exchanges** lists the jobs refused because no trust policy matched, because Mergify's GitHub App
cannot access the repository, or because the token was minted for another Mergify. A token Mergify
cannot verify is not listed: read the job's log.

Workflows run for Dependabot pull requests get an ID token like the repository's other runs, so
they no longer need `MERGIFY_TOKEN` in your [Dependabot
secrets](/integrations/dependabot#configuring-mergify_token-for-dependabot) either.

### Delete the `ci` keys

Once your repositories show only allowed exchanges, delete the `MERGIFY_TOKEN` secrets, then the
`ci` keys under **Settings → Developer → Application Keys**. Keep one for the [jobs that cannot
use OIDC](#jobs-that-keep-a-key). A key you do not delete keeps working.

## Trust policies

A policy reads as a sentence: "Jobs from *these repositories* running *these workflows* on *these
events* in *these environments*." A job gets a token when one policy matches it, and a policy
matches when all four lines do. Policies have no order and none denies: a narrow policy cannot
hold back what a broad one allows.

- **Repositories**: all repositories, the default, new ones included as Mergify's GitHub App gets
  access to them; all except the ones you pick; or only the ones you pick.

- **Events**: the CI events, the default (`push`, `pull_request`, `merge_group`, `schedule` and
  `workflow_dispatch`); the events you pick; or any event.

- **Workflows**: any workflow, the default; or the workflow files of the job's own repository,
  such as `.github/workflows/ci.yml`, and reusable workflows, optionally pinned to a ref.

- **Environments**: any environment, or none, the default; or the environments you name, which a
  job in no environment does not match.

Keep these in mind while writing one:

- `pull_request_target`, `workflow_run` and `issue_comment` are not CI events. They run with the
  repository's identity on behalf of people who cannot push to it: add them only if those
  workflows never run code they were sent.

- A workflow file of the job's own repository names the workflow the run started from. A
  reusable workflow it calls, even one of the same repository (`uses: ./.github/workflows/...`),
  is added as a reusable workflow.

- A workflow file of the job's own repository matches at any ref, so anyone who can push a branch
  can edit it. Only a reusable workflow pinned to a protected ref, or an
  [environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment)
  whose protection rules GitHub checks before the job starts, keeps them from doing so.

- A private repository can send write tokens to workflows run for pull requests from forks.
  Those runs then get an ID token, and a policy that allows `pull_request` gives the fork's code a
  token for your repository.

- An organization can have up to 100 policies.

- Deleting a policy does not revoke the tokens it already allowed: they expire within the hour.

## Jobs that keep a key

- **Pull requests from forks.** GitHub gives their runs no ID token, as it gives them no secrets
  on a public repository, so they report nothing either way (see [Pull requests without the
  secret](/ci-insights/setup/github-actions#pull-requests-without-the-secret)). If your
  organization sends secrets to workflows run for pull requests from forks, keep the key for those
  runs only:

  ```yaml
  env:
    MERGIFY_TOKEN: ${{ github.event.pull_request.head.repo.fork && secrets.MERGIFY_TOKEN || '' }}
  ```

  Every other run gets an empty value and exchanges its ID token. Drop the fork condition from
  the upload step of `mergifyio/gha-mergify-ci` then, so that fork runs upload with the key.

- **`pull_request_target`, `workflow_run` and `issue_comment` jobs that send scopes.** These jobs
  run on the base branch, and a token from their ID token reports scopes only for the commit the
  job runs on, never for the pull request's head. Mergify refuses the upload, and
  `mergify ci scopes-send` fails the step. Give those jobs a `ci` key.

- **Jobs outside GitHub Actions.** Buildkite, Jenkins and CircleCI jobs use a `ci` key.

## Tests inside containers

tox, `docker run` and Docker Compose start your tests without the job's environment. Pass
`ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` through with the [variables
the plugin reads](/test-engine/test-frameworks/pytest#tests-inside-tox-docker-or-docker-compose):
GitHub sets them in a job granted `id-token: write`, and without them the plugin has no ID token
to exchange.

## GitHub Enterprise Server

An [on-premise Mergify install](/enterprise) connected to GitHub Enterprise Server accepts the ID
tokens of that server's GitHub Actions, issued by
`https://<your-github-host>/_services/token`, and none from github.com. The clients request the
ID token for the Mergify API URL the job reaches, so set `MERGIFY_API_URL` (the
`mergify_api_url` input of `mergifyio/gha-mergify-ci`) to your install's URL, as you do with a
key.

On github.com, Mergify also accepts the issuer GitHub Enterprise Cloud lets an enterprise switch
its Actions to, `https://token.actions.githubusercontent.com/<enterprise>`. Nothing to configure.

## Troubleshooting

When Mergify refuses an exchange, its reason appears in the job log. A test framework plugin
prints it at the end of the run:

```text
Mergify won't upload this run's test results: MERGIFY_TOKEN is not set, and the
job's ID token could not be exchanged for a Mergify token.
Error: Mergify refused the job's ID token: <reason>
```

The Mergify CLI, which `mergifyio/gha-mergify-ci` runs, ends its `no Mergify credential found`
error with the same reason; `mergify ci scopes-send` prints it as a warning. What to do depends on
how the reason starts:

- `<organization> has no trust policy`: [write a trust policy](#write-a-trust-policy).

- `No trust policy of <organization> matches this job`: the reason names the job's workflow, event
  and environment, and **Exchanges** shows which line of each policy refused it. Widen a policy,
  or use **Allow Jobs Like This**.

- `Mergify's GitHub App has no access to <repository>`: add the repository to the app's
  installation in your GitHub organization settings.

- `The ID token was issued for another audience`: the job requested its ID token for another
  Mergify. Set `MERGIFY_API_URL` to the audience the reason names.

- `This Mergify accepts the ID tokens of GitHub Actions jobs from <issuer> only`: the job reached a
  Mergify that serves another GitHub. Check `MERGIFY_API_URL`.

**`MERGIFY_TOKEN is not set`, with nothing about an ID token.** The client did not try an
exchange:

- The job lacks `id-token: write`. For a reusable workflow, check the calling job.

- The run is for a pull request from a fork.

- The tests run in a container that was not passed `ACTIONS_ID_TOKEN_REQUEST_URL` and
  `ACTIONS_ID_TOKEN_REQUEST_TOKEN`.

- The plugin or CLI is not on its latest release.

The CLI prints `no Mergify credential found` instead. In a job with no ID token, the scopes upload
of `mergifyio/gha-mergify-ci` warns `Mergify token is not set, scopes will not be sent to Mergify
API`.

**`Mergify could not exchange the job's ID token`.** Mergify could not be reached, or could not
fetch GitHub's signing keys. The tests run, but this run's results are not uploaded and no test is
quarantined in it. Run the job again.

**`could not get the job's ID token: HTTP <status>`.** GitHub did not sign an ID token for the
job, with the same effect. Run the job again.

**`This Mergify CI token was issued to a job that runs on <ref>`.** A token from a job's ID token
tried to send scopes for a commit that job does not test. See [Jobs that keep a
key](#jobs-that-keep-a-key).
