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.
on:
pull_request_target:
branches: [main]
concurrency:
group: deploy-${{ github.head_ref }}
cancel-in-progress: trueFor 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.

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".

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.

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 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.