{
“@context”: “https://schema.org”,
“@type”: “TechArticle”,
“headline”: “Abusing OIDC in CI/CD: A Step-by-Step Tutorial on GitHub Actions Token Trust Chains”,
“description”: “Hands-on tutorial: configure GitHub Actions OIDC federation to cloud IAM, then walk through real misconfigurations that let attackers mint cloud credentials — and how blue teams can lock them down.”,
“author”: {“@type”: “Organization”, “name”: “Hmmnm – Cybersecurity Tutorials”},
“publisher”: {“@type”: “Organization”, “name”: “Hmmnm”},
“keywords”: “GitHub Actions OIDC trust chain abuse, CI/CD security, AssumeRoleWithWebIdentity, cloud IAM federation”
}
TL;DR: How GitHub Actions OIDC Trust Chains Get Abused
OIDC federation eliminates long-lived cloud secrets from CI/CD, but it’s only safe if your cloud trust policies pin the exact repository, environment, and ref. Permissive trust policies—wildcards in the sub claim, unpinned audiences, missing conditions entirely—let any workflow in any repo (including a fork you never intended to trust) mint a token and assume a privileged cloud role. The attacker doesn’t need to forge anything; GitHub happily signs a legitimate token, and your IAM hands over credentials.
OIDC Federation 101: How GitHub Actions Tokens Work
Traditional CI/CD authentication meant storing a static cloud access key as a GitHub secret—a credential that never rotated, lived in plaintext-adjacent storage, and would be catastrophic if leaked. GitHub Actions OpenID Connect flips that model: instead of storing a secret, your workflow requests a short-lived, GitHub-signed JWT and exchanges it for temporary cloud credentials.
Here’s the mechanics. A workflow with permissions: id-token: write sends a request to GitHub’s ACTIONS_ID_TOKEN_REQUEST_URL endpoint (via the runner’s environment, typically consumed by actions like aws-actions/configure-aws-credentials). GitHub’s token service returns a JWT signed with GitHub’s keys. Key claims inside:
- iss — the issuer, fixed at
https://token.actions.githubusercontent.com - aud — the audience, defaulting to
github-actionsbut customizable per token request - sub — the subject, a structured string encoding repo, ref, and environment (more on this below)
- job_workflow_ref, repository, ref, environment — granular claims your trust policy can also evaluate
The cloud side does the crypto: it fetches GitHub’s published JWKS, verifies the signature, checks issuer and audience, then evaluates the sub against its trust policy conditions. If everything matches, STS returns temporary credentials. No stored secrets, automatic rotation, full auditability—if the trust policy is strict. The entire security model collapses to one question: how tightly does your cloud-side trust policy constrain that sub claim?
Lab Setup: Federating GitHub Actions to Cloud IAM
To follow the attack walkthroughs, build the happy path first. Four steps:
- Create the OIDC identity provider in AWS IAM. Point it at
https://token.actions.githubusercontent.comwith audiencegithub-actions. AWS validates the TLS certificate and thumbprint automatically for this well-known issuer. - Create an IAM role with an OIDC trust policy. A correctly pinned policy looks like this:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "github-actions",
"token.actions.githubusercontent.com:sub": "repo:acme/payments-api:ref:refs/heads/main"
}
}
}]
}
- Attach a least-privilege permissions policy to that role—S3 deploy bucket, ECR push to one repository, nothing account-wide.
- Wire the workflow:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/payments-deploy
aws-region: us-east-1
That’s the correct configuration. Now let’s look at how it goes wrong.
Anatomy of the Trust Chain: Issuer, Audience, and Subject Claims
Three claims do all the security work. The issuer is effectively constant—you’re trusting GitHub’s token service, verified cryptographically. The audience defaults to github-actions but is attacker-controllable in the sense that any workflow can request a token with any audience it names. That means audience alone is not an authentication factor—it’s a routing label. Treat it as such.
The subject is where the real discrimination happens. Format examples:
repo:acme/payments-api:ref:refs/heads/main— a push to mainrepo:acme/payments-api:pull_request— a fork PR event (read-only token by default)repo:acme/payments-api:environment:production— a job in theproductionenvironmentrepo:acme/payments-api:ref:refs/tags/v1.2.3— a tag build
Your trust policy must evaluate sub with StringEquals—exact match. Anything looser and you’re trusting claims you didn’t intend to.
Misconfiguration #1: Wildcard Subject Claims in Trust Policies
The most common footgun: teams want flexibility across branches, so they reach for StringLike with a wildcard:
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:acme/payments-api:*"
}
}
Convenient—and fatal. Every event type on that repo now qualifies: pull_request tokens, tag builds from any contributor with push access, runs against any branch. Worse, a bare pattern like repo:acme/* or, in the worst cases we’ve seen in real audits, a missing sub condition entirely, means any GitHub repository in the world can mint a token and assume the role. The audience check remains, but remember: any workflow controls its own audience. The trust policy is now an open door with a sign on it.
The attacker workflow needs nothing but the role ARN:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/payments-deploy
aws-region: us-east-1
role-session-name: legit-deploy
If the token’s sub matches the wildcard, STS returns credentials. Done.
Misconfiguration #2: Unpinned Audience and Overly Broad Trust
Some teams issue custom audiences—token_for_job: prod-deploy—and build trust policies that check only the audience. This is backwards. The audience is chosen by whoever requests the token; the subject is determined by where and how the workflow runs. A trust policy matching aud == "prod-deploy" with no sub condition is equivalent to no authentication at all.
Compounding this: pull_request_target. This event runs in the context of the base repository with access to its secrets—and, if id-token: write is granted, its OIDC token minting. Untrusted fork code checked out in a pull_request_target workflow (a pattern OWASP flags in its CI/CD guidance at OWASP Top 10 CI/CD Security Risks) can therefore request a token whose sub reads repo:acme/payments-api:pull_request. If your trust policy uses the wildcard from Misconfiguration #1, that fork just inherited your production role.
Misconfiguration #3: Reusable Workflows and Environment Trust Gaps
Reusable workflows complicate the sub claim. When repo A calls a reusable workflow defined in repo B, the token’s sub reflects the caller by default, but claims like job_workflow_ref point at the called workflow. Teams building shared deploy pipelines across an org often widen trust to repo:acme/*:environment:production—meaning any repo in the org that can trigger a job in the production environment gets the role. One compromised low-value repo becomes lateral movement into your cloud.
Environments help only if they’re gated. An environment claim (environment:production) is meaningful because GitHub enforces environment protection rules—required reviewers, branch restrictions—but if the environment is created without protection rules, any workflow in the repo can claim it, and your cloud trust policy treats that as authorization. The trust chain is only as strong as its weakest GitHub-side control, not just the IAM policy.
Walkthrough: Attacker Path from Workflow Injection to Cloud Credentials
Chain it together, CTF-style. Target: acme/payments-api with a role trusting repo:acme/payments-api:*.
- Initial access. Attacker submits a malicious PR. The repo’s
pr-check.ymlusespull_request_targetand checks out the PR head withid-token: writeat the workflow level—classic workflow injection setup (see unit42’s research on this class, Palo Alto Unit 42 on GitHub repo risks). - Token mint. The injected step runs
curlagainst$ACTIONS_ID_TOKEN_REQUEST_URLwith$ACTIONS_ID_TOKEN_REQUEST_TOKEN, receiving a JWT withsub: repo:acme/payments-api:pull_requestandaud: github-actions. - Role assumption. The step calls
aws sts assume-role-with-web-identitywith the JWT and the role ARN scraped from the repo’s own workflow file. Trust policy wildcard matches. STS returnsAccessKeyId/SecretAccessKey/SessionTokenvalid for one hour. - Export and exfil. The step prints the credentials—masked as secrets or encoded—pushes them to an attacker-controlled endpoint, then the workflow “fails” innocuously. Blue teams see nothing unusual in GitHub; the attack lives entirely in CloudTrail.
Total attacker effort: one PR and ~15 lines of YAML.
Detecting Abused OIDC Tokens in Cloud Audit Logs
Every OIDC federation landing in AWS produces a AssumeRoleWithWebIdentity event in CloudTrail. Key fields:
userIdentity.type:WebIdentityUseruserIdentity.userName: the token’ssubclaimuserIdentity.sessionContext.sessionIssuer: the assumed role ARNadditionalEventData: the identity provider URL
Query patterns for your SIEM or CloudTrail Insights:
- Alert on any
AssumeRoleWithWebIdentitywhereuserNamecontains:pull_requestor a ref other than your protected branches/tags. - Alert on unusual
sourceIPAddressvalues — tokens minted on GitHub-hosted runners correlate to Azure IP ranges, not your office. - Baseline expected
repo:subjects per role; alert on any subject appearing for the first time (first-seen detection). - Correlate role assumption with downstream sensitive API calls (
s3:PutObjectto unusual buckets,iam:CreateAccessKey) within the session.
CISA and AWS both recommend continuous monitoring of federated role assumption as part of CI/CD zero-trust guidance — see AWS Security Blog on the GitHub OIDC integration for vendor-documented patterns.
Hardening Checklist: Locking Down OIDC Federation
Least privilege for a production deploy workflow, concretely:
- Pin
subwithStringEquals, neverStringLike— exact repo, exact ref, e.g.repo:acme/payments-api:ref:refs/heads/main. For deployments, preferrepo:acme/payments-api:environment:production. - Pin the audience to
github-actionsexplicitly, and setrole-session-namepatterns in workflows for log readability. - Restrict
id-token: writeto the specific job, not workflow-level — and never onpull_request_targetworkflows. - Gate production environments in GitHub with required reviewers and branch deployment policies; the IAM environment claim is only as strong as those rules.
- Least-privilege role permissions — one role per workflow purpose, no
*actions, no account-wide read. - Protect branches and tags so refs can’t be spoofed by anyone with write access.
- Cap session duration on the role (15–60 minutes) to shrink the abuse window.
- Audit existing trust policies for wildcards and missing conditions — ScoutSuite or custom IAM policy analysis over every role with
sts:AssumeRoleWithWebIdentity.
Practice It: CTF Lab Ideas and Further Reading
Build your own range:
- Exercise 1: Deploy the wildcard trust policy above, then write a workflow that assumes the role. Inspect the decoded JWT at jwt.io to map every claim.
- Exercise 2: Convert the wildcard policy to
StringEquals, rerun, and read the STSAccessDeniedto understand exactly which claim failed. - Exercise 3: Stand up a vulnerable-by-design CI repo or adapt CTF-style repos like those from the OWASP Web Security Testing Guide methodology to CI/CD contexts, and hunt permissive roles with a ScoutSuite scan.
- Exercise 4: Write a CloudTrail detection rule and test it with your own attack workflow.
Further reading: GitHub’s official OIDC documentation (Security hardening with OpenID Connect), AWS’s federation guide, and the OWASP CI/CD Top 10. The trust chain is only as strong as its strictest claim — pin everything, trust nothing by default.
Frequently Asked Questions
Is GitHub Actions OIDC federation safer than storing long-lived access keys?
Yes by default—no stored secrets and short-lived tokens—but only if the cloud-side trust policy strictly pins subject and audience claims. A permissive trust policy can be worse than a scoped static key, because it exposes a credential-minting endpoint to every workflow in the repo.
What does the GitHub Actions OIDC token’s sub claim look like?
Format like repo:owner/repo:ref:refs/heads/main or repo:owner/repo:environment:production. Wildcards or omission in trust policies are the main risk—always match with StringEquals on the full subject string.
Can a forked pull request mint OIDC tokens?
Read-only fork PRs get read-only permissions by default, but pull_request_target workflows and self-hosted runner setups can expose token minting to untrusted code. Never grant id-token: write in workflows that execute fork-supplied code.
How do I find a permissive OIDC role in AWS?
Audit IAM role trust policies for sts:AssumeRoleWithWebIdentity actions with StringLike sub patterns or missing conditions entirely. Tools like ScoutSuite or custom IAM policy scans flag these systematically.
Which cloud providers support GitHub Actions OIDC federation?
AWS, Google Cloud, Azure, HashiCorp Vault, and many others accept GitHub’s token issuer (https://token.actions.githubusercontent.com). The trust model and hardening principles are identical across providers—pin subject, pin audience, least privilege.
Related reading
- How to Hash Passwords in 2026: Argon2id, bcrypt and the NIST Baseline
- Hands-On Container Escape Lab: Privilege Escalation from Pod to Node with Real Commands
