GitHub Actions needs OIDC audience constraints

4 min read Original article ↗

ENOSUCHBLOG

Programming, philosophy, pedaling.


Aug 10, 2026     Tags: dear-github, oss, security    


TL;DR: GitHub Actions should allow end-users to express audience constraints, to make it harder for an attacker to pivot across services that use independent OIDC-bearing jobs. They could do this with relatively small syntax tweak, although the backend implications are probably nontrivial.

Like many CI/CD providers, GitHub Actions provides verifiable machine identities1 via OpenID Connect (OIDC).

These are awesome for a lot of reasons, not least of which is that they allow workflows running on GitHub Actions to federate with other (third-party) services without GitHub having to intermediate and pre-bless every interaction.

This is the backbone of how both Trusted Publishing and Sigstore work: an individual workflows on GitHub Actions presents its machine identity (via an OIDC token) to an external service, which then authenticates and for some purpose (uploading to PyPI or signing artifacts, respectively).

Unfortunately, GitHub’s mechanism for exposing OIDC tokens in workflows contains a significant weakness, one that (in my opinion) will present an increasingly serious security risk over time. This post is about that weakness.

CI/CD and OIDC#

At the core of all “OIDC in CI/CD” implementations is some mechanism that allows the workflow (pipeline definition, etc.) to request or otherwise be pre-loaded with an OIDC identity.

Here’s how that looks in GitLab CI/CD:

1
2
3
4
5
6
my-job:
  id_tokens:
    PYPI_ID_TOKEN:
      aud: pypi
  script: |
    do-something.sh --id-token "${PYPI_ID_TOKEN}"

and here’s the equivalent in GitHub Actions:

1
2
3
4
5
6
7
8
9
10
11
my-job:
  runs-on: ubuntu-latest
  permissions:
    id-token: write
  steps:
      run: |
        resp=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
          "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi")
        oidc_token=$(jq '.value' <<< "${resp}")

        do-something.sh --token "${oidc_token}"

The difference between these two is small but important: GitLab requires the OIDC audience (the aud) to be declared up-front and statically, while GitHub requires the workflow to dynamically request a token with an audience selected at runtime (the audience parameter in the HTTP request).

Why does this matter?#

First, a very quick foray into OIDC.

Under the hood, OIDC is (mostly) just OAuth 2.0, and OIDC identity tokens are just JSON Web Tokens (JWTs), with some additional2 constrains on the claims they should express.

The most important claim in an OIDC ID token is arguably sub, since it identifies the principal (the “subject”)3.

However, the second most important claim is aud, for the audience. The audience is critical because it constrains who honors the token: services that accept ID tokens should only do so when they recognize the audience as matching theirs.

In other words: the aud claim prevents an ID token that’s intentionally been issued for a specific service from being stolen by the attacker and mis-applied to another service.

This is intended as a defense-in-depth: even if an attacker manages to exfiltrate an OIDC credential, they should not be able to pivot to other services it.

It’s a flimsy defense but one that’s generally effective4, unless you give the attacker the ability to control the aud claim as well.

Unfortunately, that’s exactly what GitHub Actions enables5: id-token: write gives the job (or entire workflow) the ability to mint any ID token it pleases, with any audience.

This matters a great deal in a world (our world) where jobs that are given id-token: write also run a lot of third-party code: any vulnerability (or malware) in that code has the potential to ask for new ID tokens for audiences that it isn’t supposed to have access to.

We thought about this problem when designing Trusted Publishing, and came to the conclusion that the machine identity that Trusted Publishing uses must include the workflow name, preventing an attacker from impersonating pypi-publish.yml by inducing an ID token from aws-deploy.yml with aud: pypi.

However, this constraint is not suitable for all possible use cases: many integrations want to use just the org/repo slug as a sufficient identity, meaning that all workflows are effectively co-equal when issuing ID tokens.

What should GitHub do?#

Add constraints! Ideally, something like this:

1
2
3
4
my-job:
  runs-on: ubuntu-latest
  permissions:
    id-token: [pypi]

The basic idea here is to constrain what audiences the job can request ID tokens for. In the example above, attempting to request a token for aud: sts.amazonaws.com would cause an error, preventing a job that’s intended only for publishing to PyPI from serving as a pivot to AWS.

There are, of course, some potential downsides to this. For example, some services might (inadvisedly) require dynamic information in their audience, meaning that a value can’t be statically pre-declared. I think this is rare enough in practice to not be worth blocking a security improvement and, when they do occur, GitHub could continue to allow the id-token: write form as a (less secure!) catch-all.



Discussions: Mastodon Bluesky Reddit