Abusing OIDC in CI/CD: A Step-by-Step Tutorial on GitHub Actions Token Trust Chains

Abusing OIDC in CI/CD: A Step-by-Step Tutorial on GitHub Actions Token Trust Chains

📋 Key Takeaways
  • 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.
  • 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.
  • To follow the attack walkthroughs, build the happy path first.
  • Three claims do all the security work. The issuer is effectively constant—you're trusting GitHub's token service, verified cryptographically.
11 min read · 2,044 words
Educational & Ethical Use Only — This article is provided for educational and ethical cybersecurity research purposes only. The techniques described should only be used on systems you own or have explicit permission to test. Always follow responsible disclosure and the laws applicable to you. Mitigations are included so engineers can harden real systems.

{
“@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-actions but 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:

  1. Create the OIDC identity provider in AWS IAM. Point it at https://token.actions.githubusercontent.com with audience github-actions. AWS validates the TLS certificate and thumbprint automatically for this well-known issuer.
  2. 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"
      }
    }
  }]
}
  1. Attach a least-privilege permissions policy to that role—S3 deploy bucket, ECR push to one repository, nothing account-wide.
  2. 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 main
  • repo:acme/payments-api:pull_request — a fork PR event (read-only token by default)
  • repo:acme/payments-api:environment:production — a job in the production environment
  • repo: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:*.

  1. Initial access. Attacker submits a malicious PR. The repo’s pr-check.yml uses pull_request_target and checks out the PR head with id-token: write at the workflow level—classic workflow injection setup (see unit42’s research on this class, Palo Alto Unit 42 on GitHub repo risks).
  2. Token mint. The injected step runs curl against $ACTIONS_ID_TOKEN_REQUEST_URL with $ACTIONS_ID_TOKEN_REQUEST_TOKEN, receiving a JWT with sub: repo:acme/payments-api:pull_request and aud: github-actions.
  3. Role assumption. The step calls aws sts assume-role-with-web-identity with the JWT and the role ARN scraped from the repo’s own workflow file. Trust policy wildcard matches. STS returns AccessKeyId/SecretAccessKey/SessionToken valid for one hour.
  4. 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: WebIdentityUser
  • userIdentity.userName: the token’s sub claim
  • userIdentity.sessionContext.sessionIssuer: the assumed role ARN
  • additionalEventData: the identity provider URL

Query patterns for your SIEM or CloudTrail Insights:

  • Alert on any AssumeRoleWithWebIdentity where userName contains :pull_request or a ref other than your protected branches/tags.
  • Alert on unusual sourceIPAddress values — 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:PutObject to 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 sub with StringEquals, never StringLike — exact repo, exact ref, e.g. repo:acme/payments-api:ref:refs/heads/main. For deployments, prefer repo:acme/payments-api:environment:production.
  • Pin the audience to github-actions explicitly, and set role-session-name patterns in workflows for log readability.
  • Restrict id-token: write to the specific job, not workflow-level — and never on pull_request_target workflows.
  • 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 STS AccessDenied to 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.

Hmmnm
Published by Hmmnm

Hands-on cybersecurity tutorials, CVE breakdowns, and guided learning paths — written and lab-tested by the Hmmnm team.

🛡️ Hmmnm also delivers this expertise as a service — security testing, assessment & training.

Prabhu Kalyan Samal

Application Security Consultant at TCS. Certifications: CompTIA SecurityX, Burp Suite Certified Practitioner, Azure Security Engineer, Azure AI Engineer, Certified Red Team Operator, eWPTX v3, LPT, CompTIA PenTest+, Professional Cloud Security Engineer, SC-900, SC-200, PSPO I, CEH, Oracle Java SE 8, ISP, Six Sigma Green Belt, DELF, AutoCAD. Writing about ethical hacking, security tutorials, and tech education at Hmmnm.