A fork pull request can cancel your production deploy on GitHub Actions. It needs no write access, no secrets and no merged commit. It needs a branch name.

The sequence is three steps: fork the repository, create a branch called main, open a pull request. Nothing in that pull request has to run for the effect to land.

The concurrency group is a shared slot

Concurrency groups are strings. Jobs whose group expression evaluates to the same string share a slot, and cancel-in-progress: true cancels the job that holds it. A workflow that keys the group on a pull request's head branch is using a value the pull request author chooses.

yamlyaml
on:
  pull_request_target:
    branches: [main]

concurrency:
  group: deploy-${{ github.head_ref }}
  cancel-in-progress: true

For a fork branch named main, github.head_ref is main. The group becomes deploy-main, the same group as the trusted deploy from the base repository, and the running deploy is cancelled. The attacker never reached a runner.

Why pull_request_target makes it worse

pull_request_target runs in the context of the base repository. That is its purpose: the workflow can read secrets, write to the repository and use the base ref's cache while handling a pull request whose contents are untrusted.

A workflow in that position already takes attacker-controlled input. The concurrency group makes one more piece of that input load-bearing: a name. The attacker picks it, and the trusted side dereferences it.

The same shape, twice patched

Concurrency is not the first appearance of this pattern. GitHub has changed the platform twice for a similar class of bug.

Workflow files used to load from the pull request's base branch. An attacker could open a pull request against an old release branch and have the run execute a stale copy of the workflow that still contained a bug patched on main.

Workflow resolution before the fix
Fixed in December 2025. The run took the workflow file from the pull request's base branch, so a pull request against an old release branch could execute a stale copy that still had a known bug.

Environment branch protections were evaluated against head_ref, the fork's branch name. A fork branch called release passed a rule that meant "only the release branch can deploy".

Environment branch filter before the fix
Fixed in December 2025. The production environment compared its branch filter with the fork's branch name, so a branch called release passed a gate meant for the base repository.

Both fixes are the same move: stop accepting a name from the untrusted side as proof of identity. The concurrency bug is the part that still behaves this way.

OIDC cannot tell the difference

An ID token minted for a pull_request_target run carries the subject repo:org/repo:ref:refs/heads/main, which is byte-identical to a real push to main. A cloud trust policy that checks only the subject cannot distinguish the two.

OIDC subject claim for a pull_request_target run
A fork-triggered run and the real deploy present the same subject. A trust policy keyed on the subject alone accepts both.

The condition has to include something else: the event name, or the base ref. That is not in the default claim format, so the policy has to be written to ask for it. A role that can be assumed by any run on main is a role that can be assumed by a fork on main.

The workflow_run handoff

The documented two-workflow split has the same property. An unprivileged pull_request run uploads artifacts. A privileged workflow_run run downloads them, with write permissions and secrets, and executes something that reads the artifact.

The workflow_run artifact handoff
The privileged workflow consumes bytes the attacker wrote. The permissions on the second workflow are correct; the input is not.

The second workflow is usually the careful one. It runs only after the first finishes, it has explicit permissions, and it was designed to keep secrets away from the pull request. Then it treats the artifact as data from a trusted producer. An artifact is a file the attacker controls.

What to change

  • Do not key a concurrency group, an environment gate or a trust policy on a string the pull request author sets. Use the pull request number, the head repository's full name, or the base ref.
  • Set permissions on every workflow and job. A default token with write access turns a small mistake into a large one.
  • For OIDC, put the event and the base ref into the trust policy condition. Check the subject template your provider actually issues.
  • Treat artifacts from unprivileged runs as hostile input. Verify them; do not execute them.
  • Assume pull_request_target workflows are reachable by strangers. Keep them small, and keep secrets out of the steps that touch pull request content.

What CI can and cannot fix

No CI service can infer that a branch name came from a stranger. The naming and the trust policy live in your workflow and your cloud account.

What the platform can do is limit what a bad run reaches. preloop runs each job in a single-use microVM, so a job cannot leave state behind for the next one, and it hands a job only the credentials its workflow asks for. The rest is a review problem: the workflow decides which names it trusts, and the deploy decides which subject it accepts.

The three-step attack is worth remembering the next time a workflow needs a unique group. The unique part should come from something the attacker cannot choose.