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