Build pipeline  / field guide
Practitioner field guide · Security & Identity · 2026-09-09

The build pipeline runs strangers' code with all your keys

A CI job is the most privileged and least guarded machine most teams operate: it holds every credential you own and, on a public repository, runs code proposed by anyone on the internet. This guide reconstructs, from the postmortems of Codecov, CircleCI, Nx, tj-actions and Salesloft, why one long-lived secret in that job keeps producing total compromise, and why every serious remediation since 2021 converges on the same move: delete the standing secret and mint a short-lived, cryptographically-bound identity per job.

36 primary sources 20+ organisations 7 incidents dissected Evidence through September 2026 Read: 21 min
01

The territory

The problem stated without the acronyms, who has been forced to solve it in production, and the one thread that connects incidents that look unrelated.

State it without the technology's name and it sounds absurd. You have one machine that holds a copy of every credential your organisation uses to reach anything: your cloud account, your package registry, your artifact store, your signing keys, your database. That same machine automatically runs code, and on any open-source project the code it runs is proposed by strangers. It is the one system that is simultaneously the most privileged and the most exposed, and for most of the last decade it authenticated to everything downstream with secrets that never expired. That machine is the build pipeline, and it has quietly become the highest-value target in the software supply chain.

The reason it is worth a field guide now is that the incidents stopped being rare. Between 2021 and 2026 the same shape recurred across Codecov, Travis CI, CircleCI, PyTorch, Coinbase, the tj-actions ecosystem, Nx and Salesloft. In every case the loss was not a clever cryptographic break. It was a credential that lived in a build job and should not have, or a build job that ran attacker-controlled code and should not have. The surprise, once you line the postmortems up, is how little the attackers had to do and how completely the remediation trajectory has converged: platform vendors, package registries and cloud providers are all, independently, deleting the long-lived secret and replacing it with a short-lived identity that is bound to a specific workflow and expires in minutes.

65 days
Codecov's tampered uploader exfiltrated CI environment variables undetected; a customer, not the vendor, found it
218
Repositories that leaked secrets in the tj-actions chain, from one stolen PAT four repos upstream
500+
AWS IAM roles across 275 accounts assumable by any GitHub repository, from a missing OIDC condition
7 days
Maximum lifetime of new npm publish tokens under GitHub's 2025 plan; classic never-expiring tokens are being deprecated entirely

Scope. This guide is about the credential and trust boundary of the CI/CD job itself: how build-time secrets leak, how untrusted code gets to run with them, and how short-lived federated identity replaces them. It leans on GitHub Actions because that is where the public evidence is densest, but the failure classes are platform-independent and the GitLab and Sigstore equivalents are drawn in for contrast. It deliberately does not cover dependency and package integrity as such (typosquatting, malicious transitive dependencies, the mechanics of the Shai-Hulud worm), nor the human-endpoint compromise that seeds some of these incidents, nor artifact signing beyond where it touches CI identity. Those are adjacent guides.

Figure 1 · The build job sits astride every trust boundary at once

Everything you own

The CI job (trust boundary)

Untrusted input

Fork pull request
(anyone on the internet)

Third-party action
referenced by tag

Runner executes
workflow steps

Static secrets
injected as env vars

Cloud account

Package registry

Signing keys

Everything you own

The CI job (trust boundary)

Untrusted input

Fork pull request
(anyone on the internet)

Third-party action
referenced by tag

Runner executes
workflow steps

Static secrets
injected as env vars

Cloud account

Package registry

Signing keys

The same job ingests an event that may come from a stranger, and holds the credentials that reach everything downstream. Nothing structurally separates the untrusted input from the privileged output, which is the whole problem. Reconstructed from GitHub Security Lab and Koishybayev et al., USENIX 2022.
Diagram source
02

How it is actually built

Every CI system has the same six parts. What separates a compromised pipeline from a resilient one is how two of them are wired: how the job authenticates downstream, and how untrusted input reaches the runner.

Strip GitHub Actions, GitLab CI, CircleCI and Jenkins down and the same skeleton appears. A trigger fires from a version-control event. A workflow definition says what to run, read from somewhere in the repository. A runner executes the steps, either ephemeral (a fresh VM per job) or self-hosted (a persistent machine you own). A secret store injects credentials into the job. Downstream, a resource such as a cloud account or a package registry decides whether to honour whatever the job presents. That skeleton is not where systems differ. They differ at two joints, and both joints are where every incident in this guide happened.

The first joint is how the job proves who it is to the resource. The old answer, still the majority answer, is a long-lived secret: an AWS access key, an npm token, a registry password, stored in the secret store and injected as an environment variable. The key never expires, is valid from anywhere, and is indistinguishable from the legitimate holder the instant it is copied. The new answer, which every major platform is now pushing, is federated identity via OpenID Connect. The CI platform runs an OIDC identity provider; for each job it mints a signed JWT whose claims describe exactly what is running (which repository, which branch, which workflow, which environment). The downstream resource verifies that JWT against the provider and against a trust policy, and hands back a credential scoped to the life of the job. GitHub, GitLab and CircleCI all now document this path; GitLab sets the token's lifetime to "the job's timeout if specified, or 5 minutes if no timeout is specified" (GitLab docs), and Sigstore's Fulcio uses the identical GitHub identity token to issue short-lived signing certificates (Fulcio docs).

The second joint is where the workflow definition and the checked-out code come from relative to who can trigger the job. A standard pull-request trigger runs the fork's proposed code but, by GitHub's design, withholds secrets and write access from it. The pull_request_target trigger, introduced so that workflows could label or comment on fork PRs, relaxes that: it runs in the context of the target repository, with its secrets and a writable token, while still being triggerable by the fork. GitHub's own Security Lab named the resulting hazard the "pwn request" in 2020. The maintained static analyzer zizmor is blunt that the common mitigation folklore is wrong: "Many online resources suggest that pull_request_target and other dangerous triggers can be used securely by ensuring that the PR's code is not executed, but this is not true" (zizmor audits).

Figure 2 · The same skeleton, two authentication paths

standing secret

federated identity

VCS event
(push / PR / tag)

Workflow definition

Runner
(ephemeral or self-hosted)

How does the job
prove identity
downstream?

Long-lived token
in secret store

Per-job OIDC JWT
with sub/aud claims

Downstream resource
cloud / registry / signer

Platform OIDC provider

standing secret

federated identity

VCS event
(push / PR / tag)

Workflow definition

Runner
(ephemeral or self-hosted)

How does the job
prove identity
downstream?

Long-lived token
in secret store

Per-job OIDC JWT
with sub/aud claims

Downstream resource
cloud / registry / signer

Platform OIDC provider

The six components are common to every CI system. The red path (standing secret) and the green path (per-job OIDC identity) are the divergence point that decides blast radius. Reconstructed from GitHub OIDC docs, aws-actions/configure-aws-credentials and GitLab.
Diagram source

The runner

Ephemeral runners bound the damage of code execution to one job. Self-hosted runners on public repos are the opposite: a persistent machine reachable by any contributor's PR, which is how the PyTorch supply-chain proof of concept reached "full access to the runners" and stole persistent PATs.

Documented at: Stawinski/Khan, DEF CON 32

The workflow token

The automatic GITHUB_TOKEN was write-by-default for years. GitHub changed the default to read-only in February 2023, but only "for new repositories owned by personal accounts"; existing repos kept the permissive default, so least privilege here is opt-in for most of the installed base.

Runs this way at: GitHub, 2023

The subject claim

In the OIDC path, the sub claim is the load-bearing control: it encodes the repo, branch and workflow, and the downstream trust policy must pin it exactly. Bind it loosely and the "keyless" win becomes a role assumable by the whole internet.

Detailed at: GitHub docs, Datadog

03

The decisions that matter

Five forks in the road, each with the option the evidence favours, the option that keeps losing, and the specific condition under which the loser is actually right.

Decision: how does the job authenticate to the cloud or the registry?

Chosen
  • Per-job OIDC identity, verified against a trust policy that pins the subject
  • Nx moved to npm Trusted Publishing; CircleCI's own post-incident advice is to stop storing long-lived credentials and use OIDC
  • A stolen token is useless once the job ends
Rejected
  • Long-lived access key or publish token in the secret store
  • Valid forever, from anywhere; a single copy is total compromise, as Codecov, CircleCI and Nx each demonstrated
Flips when
  • The downstream resource cannot federate OIDC. Then the fallback is the shortest-lived token the provider will issue plus automated rotation, never a permanent key

Decision: should a workflow triggered by a fork PR have secrets and write access?

Chosen
  • No. Use the plain pull_request trigger, which withholds secrets from fork code by design
  • GitHub is now enforcing this: from December 2025 pull_request_target takes its workflow from the default branch, and checkout v7 refuses fork code under it
Rejected
  • pull_request_target with a checkout of the PR head
  • The "pwn request": it runs untrusted code with the target repo's secrets. It caused Nx, tj-actions and the 2021 Travis leak
Flips when
  • You genuinely must act on fork PRs (label, comment). Then split into two workflows: an untrusted one that builds with no secrets, and a trusted one gated on a maintainer label that never checks out PR code

Decision: how do you reference a third-party action?

Chosen
  • Pin to a full commit SHA
  • Datadog, reviewing the incidents, calls SHA pinning "the only way to prevent other versions from being used"
Rejected
  • Pin to a floating tag such as @v45 or @v1
  • Tags are mutable. The tj-actions attacker moved v1 through v45.0.7 to a malicious commit and 23,000 repos picked it up on the next run
Flips when
  • Effectively never for security. The cost is dependency-update noise, which Dependabot or Renovate absorb by bumping the pinned SHA in a reviewed PR

The remaining two decisions, runner choice and the exact shape of the trust policy, are captured in the table. The pattern across all five is the same: the convenient default trades a small amount of daily friction for an unbounded worst case, and every team that took the loss wrote the same remediation.

DecisionChosenRejectedBecauseFlips whenEvidence
Downstream authPer-job OIDC identityLong-lived key in envStolen token dies with the jobResource can't federate; use shortest-lived token + rotationGitHub, 2025
Fork PR privilegespull_request, no secretspull_request_target + checkoutPwn request runs strangers' code privilegedMust act on fork PRs; label-gate a secretless splitGH Security Lab
Action referenceFull commit SHAFloating tagTags are mutable and were movedNever for security; automate SHA bumpsDatadog, 2026
Runner for public reposEphemeral, per-jobPersistent self-hostedSelf-hosted persists attacker accessNeed special hardware; require approval + ephemeralDEF CON 32
Trust policy subjectsub pinned with exact matchMissing or wildcard subLoose sub = role assumable by any repoMany repos need it; enumerate them, don't wildcard the orgDatadog, 2023

Figure 3 · How should this job reach the thing it needs?

yes

no

yes

no / wildcard

Does the resource
support OIDC federation?

Trust policy pins
the sub claim exactly?

Shortest-lived token
the provider issues
+ automated rotation

Per-job OIDC identity
scoped to this workflow

STOP: role assumable
by any repository

yes

no

yes

no / wildcard

Does the resource
support OIDC federation?

Trust policy pins
the sub claim exactly?

Shortest-lived token
the provider issues
+ automated rotation

Per-job OIDC identity
scoped to this workflow

STOP: role assumable
by any repository

A decision tree whose leaves are actions, not "it depends". The single most common mistake, a wildcard or missing subject condition, is the branch that turns keyless auth back into a global credential. Derived from Datadog and aws-actions.
Diagram source
04

What broke in production

Seven published incidents, grouped by the assumption that failed. Three failure classes account for all of them, and naming the classes is more useful than the stories.

Lined up by root cause rather than by company, the incidents collapse into three classes. Class A, the standing secret: a long-lived credential lived in the pipeline and one copy was total compromise (Codecov, CircleCI, Salesloft). Class B, the pwn request: untrusted code ran with the target repository's privileges (Nx, the tj-actions chain, Travis CI). Class C, the confused deputy: a federated identity was trusted too broadly, so a role meant for one repository was assumable by any (the Datadog and Tinder findings). Class A is the oldest and the most expensive. Class B is the one platform vendors are now engineering out of the defaults. Class C is the failure mode the "keyless" migration introduces if the subject claim is bound carelessly, which is the trap worth flagging loudest because it hides inside the fix for Class A.

Figure 4 · The pwn-request chain, tj-actions, March 2025

"23,000 victim repos""tj-actions/changed-fil-es""reviewdog action""spotbugs workflow""Attacker (fork PR)""23,000 victim repos""tj-actions/changed-fil-es""reviewdog action""spotbugs workflow""Attacker (fork PR)"malicious PR, pwn request runsleaks maintainer PATuse PAT to overwrite v1 tagrun leaks reviewdog PATuse PAT, write access obtainedmove v1..v45.0.7 to malicious commitnext run dumps runner memoryto logssecrets exposed in 218 repos
"23,000 victim repos""tj-actions/changed-fil-es""reviewdog action""spotbugs workflow""Attacker (fork PR)""23,000 victim repos""tj-actions/changed-fil-es""reviewdog action""spotbugs workflow""Attacker (fork PR)"malicious PR, pwn request runsleaks maintainer PATuse PAT to overwrite v1 tagrun leaks reviewdog PATuse PAT, write access obtainedmove v1..v45.0.7 to malicious commitnext run dumps runner memoryto logssecrets exposed in 218 repos
Each stolen credential was used to steal the next, four repositories deep, until a token with write access to a 23,000-repo action was reached. A sequence diagram is the only honest way to show that the ordering, not any single flaw, was the attack. Reconstructed from Unit 42 and The Register.
Diagram source

Class A · The standing secret

Postmortem

Codecov: a build tool that read every env var

AssumptionA code-coverage uploader is a low-risk convenience script, not part of the trust boundary.
What happenedA credential left in a Docker image layer let an attacker alter the Bash Uploader to append $(env) and POST it to their server, harvesting secrets from every CI run that piped the script.
Blast radiusRan undetected for roughly 65 days (2021-01-31 to 2021-04-01). Found by a customer checking the script's SHA, not by Codecov.
FixSHASUM validation, later GPG signing of the uploader; the deeper lesson was that piping a remote script into CI hands it every secret in the environment.
Design ruleAnything that executes inside the job can read every secret in the job. Reduce the number of secrets in the job before you try to reduce what runs.
Postmortem

CircleCI: encryption at rest, keys in memory

AssumptionEncrypting customer secrets at rest protects them if the platform is breached.
What happenedMalware on an engineer's laptop stole a 2FA-backed SSO session cookie, giving access to production. Because CI must decrypt secrets to use them, the attacker "extracted encryption keys from a running process".
Blast radiusAll customer secrets treated as compromised; a global, mandatory rotation of tokens, keys and env vars across the customer base.
FixCircleCI's own advice: stop storing long-lasting credentials, adopt OIDC tokens.
Design ruleEncryption at rest is irrelevant to a system that decrypts to operate. If the secret can be used, it can be stolen while in use; the durable control is a short lifetime, not a strong cipher.
Postmortem

Salesloft Drift: one source-control account, 700+ victims

AssumptionOAuth tokens held by a trusted integration are safe as long as the integration is trusted.
What happenedAccess to a GitHub account (March-June 2025) enabled reconnaissance and workflow creation, leading to the AWS environment and the theft of customers' integration OAuth tokens, then mass data export.
Blast radiusOver 700 organisations had data exposed, none through their own systems, all through reused long-lived OAuth tokens.
FixMass token revocation and rotation across every downstream integration.
Design ruleA long-lived token granted to a third party is a bearer credential for everything it reaches. Its blast radius is the union of every tenant it can touch, not the security of the party holding it.

Class B · The pwn request

Postmortem

Nx s1ngularity: a PR title as code

AssumptionA workflow that only reads PR metadata cannot be exploited by the PR author.
What happenedA pull_request_target workflow interpolated an unsanitised PR title into a shell step, letting an attacker inject bash that stole the npm publishing token, then publish malicious Nx packages.
Blast radiusMalicious packages live for hours; Wiz counted exposure of 2,180 accounts and 7,200 repositories across three waves.
Fixnpm Trusted Publishers (OIDC), plus a manual approval step for all releases, removing the standing publish token.
Design ruleEvery ${{ ... }} expansion of attacker-controllable metadata is a code-injection sink. Pass such values through an intermediate environment variable, never straight into a run step.
Postmortem

tj-actions: a mutable tag over 23,000 repos

AssumptionA popular, pinned-by-tag action from a reputable maintainer is safe to trust transitively.
What happenedA pwn-request chain starting from a PAT leaked in a SpotBugs workflow (November 2024) walked through reviewdog to a token with write access to tj-actions, whose tags were then moved to a commit that dumped runner memory to logs.
Blast radiusThe action was used by 23,000+ repositories; secrets were actually exposed in 218. The intended target was Coinbase, who detected and blocked it.
FixRevoke the PAT, move to passkeys, minimise bot permissions; downstream, pin actions by SHA.
Design ruleA tag is a mutable pointer, not a version. Transitive trust in a tag is trust in whoever can move it, four maintainers deep.

Class C · The confused deputy

Research

Missing sub condition: a role for the whole internet

AssumptionConfiguring an IAM role to trust GitHub's OIDC provider scopes it to your repositories.
What happenedTrust policies that specified the audience but omitted the sub condition. As Datadog put it, "a GitHub Action from any GitHub repository can assume the role." One real case came from a config-parsing bug that silently dropped the subject condition.
Blast radiusA scan found over 500 role ARNs across 275 AWS accounts assumable by any repo; researchers reached private repositories mirrored to CodeCommit.
FixPin both aud and an exact sub; GitHub later moved to immutable subject claims (owner-id + repo-id) so a recycled name cannot mint a matching token.
Design ruleMoving from a secret to a federated identity moves the risk from theft to misconfiguration. The subject condition is now the credential; test it the way you would have rotated the token.
Postmortem

Travis CI: fork builds that leaked secure env vars

AssumptionSecure environment variables are withheld from pull-request builds coming from forks.
What happenedA 2021 flaw (CVE-2021-41077) caused Travis to include secure env vars of all public projects in fork PR builds, exposing signing keys, credentials and API tokens.
Blast radiusAny public project using Travis in the affected window; the disclosure itself drew criticism for arriving "with no analysis, no security report, no post mortem."
FixThe bug was patched; the class is now engineered out by GitHub withholding secrets from fork PRs by default.
Design ruleThe fork-PR-leaks-secrets class is a decade old and keeps recurring. Assume any secret reachable from a fork build is already public and design so that set is empty.
The pattern under all seven

No incident here required breaking cryptography or finding a zero-day in the platform. Each was a credential that should not have been reachable, or code that should not have run privileged. The academic measurements say why this keeps happening: Koishybayev et al. found 99.8% of workflows overprivileged, and ARGUS found injectable code in 5,298 workflows. The defaults produce the vulnerability at population scale, so the fix has to be a default change, which is exactly what the platforms are now shipping.

05

Numbers you can plan against

The figures that size the problem and the fix. Every row carries its source and the date it was true; the note below separates what was measured from what is a direction of travel.

MetricValueAtContextAs ofSource
Undetected exfiltration window~65 daysCodecovTampered uploader read CI env vars; found by a customer's SHA check2021Codecov
Repos using the compromised action23,000+tj-actionsPinned mostly by mutable tag2025Wiz
Repos that actually leaked secrets218tj-actionsFrom one PAT four repos upstream2025Unit 42
Accounts / repos exposed2,180 / 7,200NxOne stolen npm publish token, three waves2025Wiz
Orgs hit via reused OAuth tokens700+Salesloft DriftNone breached in their own systems2025Google TIG
IAM roles assumable by any repo500+ / 275 acctsDatadog scanMissing sub condition2023Datadog
Workflows overprivileged (read-write)99.8%447k workflowsAcademic corpus scan2022USENIX
Workflows with code injection5,2982.78M scannedARGUS taint analysis2023USENIX
Secrets leaked to public GitHub23.8M2024 calendar year+25% year on year2025GitGuardian
2022-leaked secrets still valid70%GitGuardianLong-lived secrets are rarely revoked2025GitGuardian
GitLab CI ID token lifetime (default)5 minGitLabOr the job timeout if set2026GitLab
New npm publish token max lifetime7 daysnpm / GitHubClassic tokens being deprecated2025GitHub
Read these carefully

Measured: the Codecov window, the tj-actions counts, the Nx and Salesloft exposure figures, and the two academic corpus numbers are all from primary incident reports or peer-reviewed scans. Direction of travel, not a floor: the 5-minute and 7-day lifetimes are the current defaults or announced plans, and are trending shorter, not longer; treat them as ceilings to design under, not guarantees. Derived: the gap between 23,000 repos exposed and 218 that leaked is roughly a 1% realisation rate for this particular payload, which says the loss was bounded by the attacker's collection method, not by any control the 22,800 other repos had in place. There are no vendor marketing figures in this table; that was deliberate.

Figure 5 · What replaces the standing secret

"Cloud / registry""Platform OIDCprovider""Runner (this job)""Cloud / registry""Platform OIDCprovider""Runner (this job)"token dies with the jobrequest ID token for this jobsigned JWT (sub, aud, exp ~5min)present JWT to assume role / publishfetch keys, verify signaturecheck sub + aud againsttrust policyshort-lived credential, scoped to job
"Cloud / registry""Platform OIDCprovider""Runner (this job)""Cloud / registry""Platform OIDCprovider""Runner (this job)"token dies with the jobrequest ID token for this jobsigned JWT (sub, aud, exp ~5min)present JWT to assume role / publishfetch keys, verify signaturecheck sub + aud againsttrust policyshort-lived credential, scoped to job
The per-job OIDC handshake: the credential the runner ends up holding is scoped to this workflow and expires in minutes, so a copy stolen from the job is worthless once the job ends. Reconstructed from GitHub OIDC docs and GitLab.
Diagram source
06

The evidence wall

Every source behind this page, graded. Filter by kind.

Postmortem Codecov2021-04

Bash Uploader Security Update

The tampered uploader appended $(env) to an exfiltration POST, reading every secret in every CI run that piped it, undetected for ~65 days until a customer's SHA check.

Carry forwardAnything that executes in the job can read every secret in the job.
about.codecov.io/security-update/
Postmortem CircleCI2023-01

January 4, 2023 security incident report

A stolen SSO session cookie reached production; the attacker "extracted encryption keys from a running process," making encryption at rest moot. Vendor advice: adopt OIDC.

Carry forwardA secret you can use is a secret that can be stolen in use; lifetime beats cipher.
circleci.com/blog/jan-4-2023-incident-report
Postmortem Nx (Nrwl)2025-08

S1ngularity: what happened, what we learned

A pull_request_target workflow interpolated an unsanitised PR title into bash, stealing the npm token. Remediated with Trusted Publishers and manual approval.

Carry forwardEvery expansion of attacker metadata is an injection sink; route it through an env var.
nx.dev/blog/s1ngularity-postmortem
Postmortem Unit 42 (Palo Alto)2025-03

GitHub Actions supply chain attack: Coinbase to tj-actions

Reconstructs the four-repo pwn-request chain from a leaked SpotBugs PAT through reviewdog to tj-actions, whose tags were moved to a memory-dumping commit. 218 repos leaked secrets.

Carry forwardA tag is a mutable pointer; trusting it is trusting whoever can move it.
unit42.paloaltonetworks.com/github-actions-supply-chain-attack/
Advisory GitHub Advisory DB2025-03

CVE-2025-30066 (GHSA-mrrh-fwg8-r2c3)

The authoritative record for the tj-actions compromise: secrets discoverable by reading Actions logs, including access keys, PATs, npm tokens and private RSA keys.

Carry forwardSecrets printed to build logs are exfiltrated the moment logs are readable.
github.com/advisories/GHSA-mrrh-fwg8-r2c3
Eng blog Tinder Tech2023-04

Identifying vulnerabilities in GitHub Actions & AWS OIDC

Independent confirmation of the missing-subject class from a second team, with a black-box assessment tool and real case studies of externally assumable roles.

Carry forwardTwo teams independently found the same misconfiguration in the wild; it is the default failure of OIDC adoption.
medium.com/tinder/identifying-vulnerabilities-in-github-actions-aws-oidc-configurations
Eng blog GitHub Security Lab2020-08

Preventing pwn requests

GitHub's own team named and explained the pull_request_target hazard five years before the incidents it caused: such workflows can "steal or use a GITHUB_TOKEN."

Carry forwardThe vulnerability was documented for years before it was exploited at scale; knowing is not defaulting.
securitylab.github.com/resources/github-actions-preventing-pwn-requests/
Eng blog Datadog Security Labs2026-06

The case for GitHub Actions security

Distils the incident wave into three controls: pin actions to a SHA ("the only way"), avoid pull_request_target, and restrict token permissions.

Carry forwardThree defaults, applied together, remove most of the blast radius the postmortems describe.
securitylabs.datadoghq.com/articles/case-for-github-actions-security/
Research GitGuardian2025-03

The State of Secrets Sprawl 2025

23.8M secrets leaked to public GitHub in 2024, up 25% year on year; 70% of secrets leaked in 2022 are still valid, because long-lived credentials are rarely revoked.

Carry forwardThe population of live, leaked, long-lived secrets is vast; a short lifetime is the only remediation that scales.
blog.gitguardian.com/the-state-of-secrets-sprawl-2025/
Paper Koishybayev et al. (USENIX)2022-08

Characterizing the Security of GitHub CI Workflows

Across 447k workflows in 213k repos, 99.8% were overprivileged with read-write repo access and 23.7% were triggerable by a pull request while using the repo's code.

Carry forwardOverprivilege is the population default, so only a default change fixes it.
usenix.org/system/files/sec22-koishybayev.pdf
Paper Muralee et al. (USENIX)2023-08

ARGUS: staged static taint analysis of GitHub workflows

Taint analysis over 2.78M workflows found code injection in 5,298 workflows and 80 actions, a discovery rate 7x higher than pattern-based scanners.

Carry forwardInjection in Actions needs taint tracking to find; grep-style scanners miss most of it.
usenix.org/system/files/usenixsecurity23-muralee.pdf
Talk Khan & Stawinski (DEF CON 32)2024-08

Grand Theft Actions: abusing self-hosted runners

Demonstrated, against PyTorch and others, that "insecure defaults" let a fork PR reach a self-hosted runner and backdoor major projects.

Carry forwardA self-hosted runner on a public repo is an internet-reachable persistent machine; treat it as such.
media.defcon.org · DEF CON 32 presentation PDF
Research Khan & Stawinski2024-01

Playing with Fire: a supply chain attack on PyTorch

A one-line typo PR made them "contributors" and gave "full access to the runners," used to steal GitHub and AWS tokens and reach PyTorch releases.

Carry forwardContributor status is not trust; first-time contributor runs need approval gates.
johnstawinski.com/2024/01/11/playing-with-fire
Platform change GitHub2026-06

Safer pull_request_target defaults for checkout

actions/checkout v7 "refuses to fetch fork pull request code in pull_request_target and workflow_run workflows," removing the pwn-request default from the most common action.

Carry forwardThe platform is now engineering the dangerous default out; adopt the new default rather than documenting around the old one.
github.blog/changelog/2026-06-18-safer-pull_request_target-defaults
Source zizmor (Woodruff)2026-09

zizmor audits: dangerous-triggers, template-injection

The maintained static analyzer states plainly that "run it safely" folklore for pull_request_target is false, and treats all metadata expansions as injection.

Carry forwardPut a taint-aware linter in CI; the safe-usage advice you will otherwise follow is wrong.
github.com/zizmorcore/zizmor · docs/audits.md
Source aws-actions2026-09

configure-aws-credentials README

The reference OIDC client bakes in an exact-match sub condition and warns against loose operators: "Avoid ForAllValues: in Allow statements."

Carry forwardCopy the exact-subject trust policy; the convenient looser forms are the vulnerability.
github.com/aws-actions/configure-aws-credentials · README
Source Kubernetes SIG Auth2020

KEP-1205: Bound Service Account Tokens

The same principle predates CI OIDC: legacy JWTs "are not audience bound" and "not time bound," so a compromised one is valid until deleted. New tokens bind audience and time.

Carry forwardAudience-and-time binding is the general fix for bearer tokens, not a CI-specific trick.
github.com/kubernetes/enhancements · KEP-1205
Source GitLab2026-09

OIDC authentication using ID tokens

The second major CI vendor implements the identical pattern; the ID token's lifetime is "the job's timeout if specified, or 5 minutes if no timeout is specified."

Carry forwardPer-job short-lived identity is now the cross-vendor default, not a GitHub peculiarity.
gitlab.com · ci/secrets/id_token_authentication
07

Build a miniature, then productionise it

Do these in a throwaway org against a throwaway cloud account. The middle rungs cross from toy to production-shaped; the ones after are where reading becomes judgement.

Reproduce a template injection

Write a workflow with a run: step that echoes ${{ github.event.issue.title }}. Open an issue titled foo"; whoami; echo " and watch the command execute.

Done when: your injected command appears in the job log.  Teaches: ${{ }} is string substitution before the shell sees it, so metadata is code.

Watch a standing secret leak from a fork

Add a static cloud key as a repo secret. Add a pull_request_target workflow that prints env. From a second account, open a fork PR and read the secret in the log.

Done when: you retrieve the secret from an account with no write access to the repo.  Teaches: the pwn request and the blast radius of one env-injected secret.

Replace the secret with OIDC

Delete the static key. Create an AWS role trusting GitHub's OIDC provider with an exact sub match, and use aws-actions/configure-aws-credentials to assume it.

Done when: the job calls AWS with no stored key and the credential expires after the run.  Teaches: per-job identity, and that a stolen credential now dies with the job.

Break the trust policy on purpose

Change the sub condition to a wildcard, or remove it. From an unrelated repo you control, assume the same role.

Done when: a repo that should have no access mints AWS credentials.  Teaches: the confused-deputy failure the OIDC migration introduces if sub is loose.

Pin everything and add a linter

Replace every @v3-style reference with a full commit SHA. Add zizmor to CI and set the automatic GITHUB_TOKEN to read-only by default.

Done when: zizmor passes and no action is referenced by a mutable tag.  Teaches: supply-chain pinning plus detection that catches the next dangerous trigger before merge.

Publish a package with Trusted Publishing

Set up npm (or PyPI/crates) Trusted Publishing so a release is authenticated by the workflow's OIDC identity, with no publish token anywhere in the repo.

Done when: a tagged release publishes with provenance and zero stored registry credentials.  Teaches: the same identity model extends from cloud access to package release.

Make the runner ephemeral and gated

If you must use a self-hosted runner, run it per-job in a fresh container and require manual approval before workflows run for first-time contributors.

Done when: a fork PR cannot run on your runner without an approval, and each job starts from a clean machine.  Teaches: the runner is a trust boundary; persistence and auto-run are what attackers need.

08

Keep hunting

The queries that actually surfaced this material. The domain vocabulary (pull_request_target, sub claim, pwn request, Trusted Publishing) is what turns a vague search into the primary source.

The incidents themselves

  • <vendor> "incident report" secrets rotate "environment variables"
  • github actions supply chain "pull_request_target" postmortem
  • "we compromised" OR "we executed" supply chain CI/CD self-hosted runner
  • npm token stolen "trusted publishing" remediation postmortem

The mechanism and the fix

  • github oidc "sub" claim missing trust policy assume role research
  • "pwn request" pull_request_target site:securitylab.github.com
  • GITHUB_TOKEN default permissions read-only changelog
  • configure-aws-credentials README "sub" immutable subject

The academic and source layer

  • "characterizing the security" github ci workflows usenix
  • ARGUS taint analysis github actions code injection
  • path:docs zizmor dangerous-triggers template-injection
  • KEP bound service account tokens audience time bound

Sizing the population

  • state of secrets sprawl leaked github valid still active
  • oidc "id token" lifetime CI job "5 minutes" OR "expire"
  • threat intelligence OAuth tokens supply chain "700 organizations"
  • trusted publishing rubygems crates.io npm nuget adopted
09

References

  1. Codecov, Bash Uploader Security Update Codecov, 2021-04-15. Checked 2026-09-09.
  2. Codecov, Post-Mortem / Root Cause Analysis (April 2021) Codecov, 2021-04. Checked 2026-09-09.
  3. CircleCI, Incident report for January 4, 2023 security incident CircleCI, 2023-01-13. Checked 2026-09-09.
  4. Nx, S1ngularity: What Happened, How We Responded, What We Learned Nrwl, 2025-08. Checked 2026-09-09.
  5. Wiz, s1ngularity's aftermath: analysis of the Nx supply chain attack Wiz, 2025-08. Checked 2026-09-09.
  6. Unit 42, GitHub Actions Supply Chain Attack: Coinbase to tj-actions/changed-files Palo Alto Networks, 2025-03-21. Checked 2026-09-09.
  7. Wiz, GitHub Action tj-actions/changed-files supply chain attack (CVE-2025-30066) Wiz, 2025-03-15. Checked 2026-09-09.
  8. GitHub Advisory Database, GHSA-mrrh-fwg8-r2c3 (CVE-2025-30066) GitHub, 2025-03-15. Checked 2026-09-09.
  9. The Register, Stolen SpotBugs tokens sparked the massive GitHub attack The Register, 2025-04-07. Checked 2026-09-09.
  10. The Register, Travis CI quietly fixed a bug that exposed secret keys (CVE-2021-41077) The Register, 2021-09-15. Checked 2026-09-09.
  11. Google Threat Intelligence, Widespread Data Theft Targets Salesforce Instances via Salesloft Drift Google Cloud / Mandiant, 2025-08. Checked 2026-09-09.
  12. Tafani-Dereeper, No keys attached: Exploring GitHub-to-AWS keyless authentication flaws Datadog Security Labs, 2023-07-27. Checked 2026-09-09.
  13. Tinder Security Labs, Identifying vulnerabilities in GitHub Actions & AWS OIDC Configurations Tinder, 2023-04. Checked 2026-09-09.
  14. Lobacevski, Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests GitHub Security Lab, 2020-08-14. Checked 2026-09-09.
  15. Toomey, The case for GitHub Actions security Datadog Security Labs, 2026-06-02. Checked 2026-09-09.
  16. Woodruff et al., zizmor audits reference (dangerous-triggers, template-injection) zizmorcore, retrieved 2026-09-09. Checked 2026-09-09.
  17. Koishybayev et al., Characterizing the Security of GitHub CI Workflows USENIX Security '22, 2022-08. Checked 2026-09-09.
  18. Muralee et al., ARGUS: A Framework for Staged Static Taint Analysis of GitHub Workflows and Actions USENIX Security '23, 2023-08. Checked 2026-09-09.
  19. Stawinski, Playing with Fire: How We Executed a Critical Supply Chain Attack on PyTorch John Stawinski IV, 2024-01-11. Checked 2026-09-09.
  20. Khan & Stawinski, Grand Theft Actions: Abusing Self-Hosted GitHub Runners at Scale DEF CON 32, 2024-08. Checked 2026-09-09.
  21. GitHub, Updating the default GITHUB_TOKEN permissions to read-only GitHub Changelog, 2023-02-02. Checked 2026-09-09.
  22. GitHub, Actions pull_request_target and environment branch protections changes GitHub Changelog, 2025-11-07. Checked 2026-09-09.
  23. GitHub, Safer pull_request_target defaults for GitHub Actions checkout GitHub Changelog, 2026-06-18. Checked 2026-09-09.
  24. GitHub, Our plan for a more secure npm supply chain GitHub, 2025-09. Checked 2026-09-09.
  25. aws-actions, configure-aws-credentials (OIDC trust policy, immutable sub) Amazon Web Services, retrieved 2026-09-09. Checked 2026-09-09.
  26. GitHub Docs, About security hardening with OpenID Connect GitHub, retrieved 2026-09-09. Checked 2026-09-09.
  27. Kubernetes SIG Auth, KEP-1205: Bound Service Account Tokens Kubernetes, retrieved 2026-09-09. Checked 2026-09-09.
  28. GitLab, OpenID Connect (OIDC) Authentication Using ID Tokens GitLab, retrieved 2026-09-09. Checked 2026-09-09.
  29. Sigstore, Fulcio: How certificate issuing works Sigstore, retrieved 2026-09-09. Checked 2026-09-09.
  30. GitGuardian, The State of Secrets Sprawl 2025 GitGuardian, 2025-03-19. Checked 2026-09-09.