The PHP community barely avoided a disaster
14 May 2026 · Damien Retzinger
Yesterday, the PHP community barely avoided an absolute disaster - a supply-chain vulnerability on the scale of a nuclear meltdown. In my opinion, this had the potential to be one of the most severe security incidents ever to hit the PHP community, and we only escaped it by a thin margin. Today, I want to talk about it because I need people to appreciate the severity of what we almost saw, and to level-set expectations of what we may see moving forward.
TLDR: Update your composer version to a patched version, make sure you're not pinning it in your
Github Actions anywhere.
The situation
In short, for a ~14-hour window yesterday (2026-05-12 ~10:00pm UTC → 2026-05-13 ~02:30pm UTC), any PHP project that:
- was hosted on Github,
- ran
composer installaftershivammathur/setup-phpin Github Actions, - ran that workflow against its
mainbranch, and - was triggered by
on: push,on: pull_request_target, oron: schedule(other triggers are likely impacted, but these are the most common)
had a chance that the GITHUB_TOKEN (typically write-enabled) would be publicly logged to the
Actions log on every build.
- https://github.com/composer/composer/security/advisories/GHSA-f9f8-rm49-7jv2
- https://www.cve.org/CVERecord?id=CVE-2026-45793
For those ~14 hours, any malicious actor monitoring one of these repos could have pushed new tags and commits without anyone noticing. If "properly" leveraged, this would have been the largest supply-chain breach the PHP ecosystem has ever seen.
To make matters worse, we're not even done yet. The same change may roll out again in a week, at Github's discretion. And there's a chance it bleeds beyond PHP into other ecosystems.
How I came to uncover this
I maintain a set of Github Actions for the Magento community. They exist to lower the barrier to entry into the Magento and Mage-OS ecosystems, so that a company wanting to ship a store or extension can get working CI without much fuss.
For me, that means maintaining a wide variety of Github Actions that work across many versions of Magento and Mage-OS.
To handle that, I maintain a fairly complex action called supported-version,
which holds the compatibility matrix for every supported version of Magento and Mage-OS.
While I was working on this action for the Magento 2.4.9 release yesterday evening, my tests started failing
intermittently after some small changes to supported-version. The failures didn't track with
anything I'd changed.
When I went to debug the failure, I saw the following:
I was surprised more than anything. My first instinct was that I'd screwed up some bash script processing the
COMPOSER_AUTH I feed into my actions. I've seen secrets in my logs before whenever a script
clobbers a string with newlines, so this looked like my fault.
It took me about an hour (I'm not a very fast person) to realize that my actions weren't at fault. Nothing I'd changed could have triggered this outcome.
Shortly thereafter, I concluded that something outside my actions was wrong, and wrong in a way that probably wasn't unique to me. So I asked Claude, giving it all the context I'd just uncovered. Claude pointed out two things I'd "seen" but not "noticed":
- The logged token wasn't a typical PAT. It had a Github App ID embedded in it, specifically
ghs_15368. That small an ID suggests a very old Github App, most likelygithub-actions[bot]. shivammathur/setup-phpautomatically inserts that token intoCOMPOSER_AUTHas thegithub-oauthvalue.
At this point I still wasn't too concerned. I naively thought this was a one-off bug in how the token had been
generated. I knew the Github Actions token expires when the job ends; I'd looked at workflow token security on
my own repos before. And the leak was on a pull_request, so the token's permissions were already
fairly restricted. I didn't lose sleep over it.
I merged my changes to my main branch from my pull request, fully expecting that this would recover
and I could move about my day. Then, my main branch CI failed on a completely different job and I
became concerned. Earlier yesterday, @tanstack/router
was breached because their main branch's cache was poisoned by a PR, I was very concerned that
something like that was going on here. It was at this point, with a read-write token visible in my
logs that I knew something was up and that I had to dig in and act quickly. I had claude triage the error
message from the log:
In BaseIO.php line 143:
figuring that it would do a better job scowering composer's project code for the offending line faster than I would.
It uncovered immediately:
// allowed chars for GH tokens are from https://github.blog/changelog/2021-03-04-authentication-token-format-updates/
// plus dots which were at some point used for GH app integration tokens
if (!Preg::isMatch('{^[.A-Za-z0-9_]+$}', $token)) {
throw new \UnexpectedValueException('Your github oauth token for '.$domain.' contains invalid characters: "'.$token.'"');
}
It also uncovered, and huge props to Claude here, this would have taken me hours on my own, that Github was rolling out a change to their token formats.
At this point I knew, very distinctly, that "shit's fucked". Which is my inner child telling me (and now you) that something is undeniably wrong.
How I acted
Here's what I did, in order:
- I disabled all actions on my
graycoreioorg. Better safe than sorry. - I pinged the Mage-OS Discord and asked them to pause all Github Actions immediately.
- I submitted a security advisory report to the
composerproject based on my quick analysis. - I reached out to @jessicasachs, who I'd previously connected with on
@faker-js/faker, hoping she could quickly and quietly get me in touch with people at the large frameworks at risk. At my request, she reached out to @taylorotwell and asked him to pause Laravel's Github Actions. - I pinged the
modmailuser on Laravel's Discord.- Shortly thereafter, their actions were disabled. I'm not sure which channel did it.
- I called a colleague, @davidlambauer, and started trawling Github repos to confirm I wasn't the only one affected.
- I pinged @barryvdh on slack informing him to disable his Github Actions
As an aside: I'm a small fish compared to many of the well-known developers in the PHP ecosystem. I didn't know if anyone would take me seriously, and I wanted to make sure no one was hurt simply because I wasn't loud enough. I fully understand responsible disclosure. I've filed several HackerOne reports before, and I've received bounties for some of them. But when the breadth of concern is this large, this painfully obvious, this critical, and it had been four hours since I'd opened the advisory without a response, I felt I needed to take my own action through quiet, fast channels to make sure the largest parts of the ecosystem were protected as quickly as possible. What were the chances I could get @naderman or @seldaek on a call minutes after I submitted an advisory? Worse, what were the chances I could get a HackerOne report submitted and reviewed by Github in the same timeframe?
The actual impact
In reality, likely no one was compromised. In reality, likely
no one next week will be compromised when Github re-rolls the change out. Composer has already issued
patches to all major versions of composer combined (even unsupported non-LTS versions!). shivammathur/setup-php
has already bumped their mainline to update to a secure version of composer.
That being said, the bulk of the reason that this wasn't as impactful is primarily due to three components:
First, Composer's validator fails fast. The exception bubbles up and the job dies. This causes the GITHUB_TOKEN
to be revoked the moment the job exits. From the attacker's perspective, the token is live for the seconds
between Symfony Console writing the message to stderr and the runner tearing the job down. That window is real,
but it's narrow. To be clear, it's not narrow enough to be unexploitable. I have a PoC which showcases
direct writes to main using this stolen token in this very narrow window. With a sufficiently
sophisticated polling setup, you can do this in under a second.
The second reason is that the failure was loud. While I was running through Github trying to figure out whether
I was the only person hit, I could see the failure landing in CI logs across most of the top PHP repos: Laravel,
Composer itself, WooCommerce, OpenEMR, Sylius, fruitcake/laravel-debugbar and many other smaller
projects. Anyone watching their own CI saw it within minutes. If it was quiet, this would have gone on far
longer. Who reads the build logs of a passing job?
The third reason is that Composer shipped patched releases (2.9.8, 2.2.28, 1.10.28) and the advisory went out the next morning at 09:34 UTC. Github paused the format rollout shortly afterward. The response time from notification to resolution was remarkable.
The potential impact
Now imagine someone who saw what I saw earlier than I did, and who decided to do something with it instead of reporting it.
The primitive you have is: GITHUB_TOKEN from a push event on a repo's default branch,
printed in plaintext to a world-readable log, live for some number of seconds before the job ends. The token's
permissions vary by repo, but for the long tail of older repos and most maintainer-configured workflows, the
default is still contents: write, the legacy "permissive" default. That's enough to push commits
and tags to the repo it came from.
Worse, the leak doesn't necessarily end precisely when the failing step ends. A pattern I have in my own workflows, and that a lot of Magento and broader PHP workflows have, looks like this:
- name: Upload test sandbox dir
uses: actions/upload-artifact@v6
if: failure()
with:
name: sandbox-data-${{ steps.magento-version.outputs.version }}
path: /home/runner/work/infrastructure/magento2/dev/tests/integration/tmp/sandbox-*
retention-days: 3
The intent is fine: when CI fails, freeze the workspace state into an artifact so a human can debug it later. In this bug's context, the step compounds the leak.
The core concept is that it extends the live-token window. The upload runs after Composer has crashed and printed the token to stderr, but before the runner can tear the job down and revoke the token. Until the upload finishes, the token is still accepted by the Github API. For a small sandbox that's seconds; for a fat integration-test fixture with database dumps it's many minutes. An attacker polling failed runs gets a longer window to act, not a shorter one.
In the PHP ecosystem, push access to a repo is push access to Packagist. Packagist polls Github for new tags.
The moment you push v1.2.4 to vendor/package, Packagist publishes it, and the next
composer install or composer update anywhere in the world pulls your release. Composer
plugins execute code during install. So a poisoned tag on a sufficiently popular package is remote
code execution on every developer machine, CI runner, and production deploy that runs composer
install between the tag landing and the rollback.
Look at the names on the list above. composer/composer itself was failing in CI during the window.
So was laravel/framework. So was sylius. A successful exfil-and-push against any one
of those is a top-fifty-in-history supply chain incident. Against several of them simultaneously, with the same
primitive, it would be the largest one ever in PHP and probably top three across all ecosystems.
The Magento angle, since it's the corner of the world I actually live in: Magento and Mage-OS distributions are
Composer projects. Composer self-update pulls from getcomposer.org, which redirects to Github
releases. A poisoned composer binary would execute under every Magento store's deploy pipeline at
the next build (I suspect). Most e-commerce platforms do not have the supply-chain hygiene of, say, a Rails
shop. The blast radius would not have been bounded by "who reads Hacker News".
The mitigating factors
A short list of the things that, intentionally or otherwise, kept this from going off:
- Composer fails fast and fails hard. The token leaks at the moment the job is dying. The TTL from "token printed" to "token revoked" is the time it takes for Symfony Console to render the exception and the runner to tear down the job. That's typically seconds, not minutes. For an attacker, you have to be polling failing builds, parsing the leaked value, and using it inside that window. Doable, but not effortless.
GITHUB_TOKENis repo-scoped. Even a successful exfil againstlaravel/frameworkdoesn't get you anywhere nearlaravel/passport. Each repo's token can only act against that repo.- The 6-hour ceiling. Github-hosted runners cap workflow tokens at 6 hours, self-hosted at 24. Even in the worst case of a long-running matrix job that hangs after the leak, the token isn't a long-lived credential.
- Branch protection blocks the obvious move. Pushing to
maindirectly on a protected branch fails. The interesting move was pushing tags, which most repos do not protect. So branch protection alone wasn't a real barrier, but it would have slowed an attacker who didn't think to try tags first. - The rollout was gradual. Not every repo was on the new token format at the same time. At any given moment, only a fraction of the affected universe was actively leaking.
- Composer maintainers moved fast. From advisory submission to patched releases on three branches was hours, not days. I can't speak with enough for credit to Nils Adermann and Jordi Boggiano for that.
None of these are the kind of defense you'd want to bet on. But stacked together, they're the reason this is a story about a near-miss and not about cleaning up a real incident.
Proof of Concept
Any repo that was (or will be) migrated to the new ghs_<id>_<base64url-JWT> token
format with a vulnerable composer version will exhibit the leak.
name: leak
on: [push]
jobs:
leak:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
tools: composer:v2.8.1
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}}'
- run: composer install
Observed log fragment on an affected repo
In BaseIO.php line 143:
Your github oauth token for github.com contains invalid characters:
"ghs_1234567890_AbCd-EfGhIjKl_MnOpQrStUvWxYz0123456789..."
The value above is the live workflow GITHUB_TOKEN. The Github Actions secret masker registered the
token at job start, but Symfony Console's renderer reframes the message before it reaches stderr, which is
enough to defeat the masker's substring match.
The ingredients you need for the leak are:
- Github's new secret format
- A workflow that pins
composerto an unpatched version - A workflow that runs on
push,pull_request_target, orschedule(so the token is write-enabled by default) - A step that pipes
GITHUB_TOKENintoCOMPOSER_AUTH(shivammathur/setup-phpdoes this automatically when the env var is set) - A
composerinvocation that triggersloadConfiguration()(many subcommands). Remove any one of those and the leak does not fire.
If you want to verify your own repo is on the new format without running the PoC, fetch the workflow's GITHUB_TOKEN
value into a step that prints its length or checks for -, you do not need to ship the value off the
runner to confirm.
Now what?
What a typical public PHP maintainer has to do
- Upgrade Composer. Bump to
2.9.8,2.2.28, or1.10.28(the three patched lines). If your workflows pin a Composer version, unpin it or bump the pin. - Tighten
permissions:. Don't rely on the legacy permissive default. Add an explicitpermissions:block at the top of every workflow (or job) with the minimum scope each step needs. For most CI jobs that meanscontents: readand nothing else. - Protect your tags. The blast radius of this leak is overwhelmingly "an attacker pushes a
tag and Packagist publishes it". Enable Github's tag protection rules, or branch protection rules covering
refs/tags/*, so a leaked token can't mint a release. - Sweep your Actions logs for the window. Look at any
failureruns between 2026-05-12 ~22:00 UTC and 2026-05-13 ~14:30 UTC. If you see aghs_…value in any rendered output. Review these jobs for composer pins. - Check your tag and commit history for the window. Run
git log --since='2026-05-12T22:00Z' --until='2026-05-13T14:30Z' --alland verify everything is yours. Same forgit tag --sort=creatordate. Anything created in the window that you don't recognise warrants investigation. - Deploy the fixes before the next rollout. Github will resume the format change. The above is the maintenance bar for surviving the next wave without leaking again.
What Github has to do
- Harden the runner secret masker against console reframing. Today it matches registered
values as exact substrings. The instant Symfony Console (or Rich, or any other renderer) wraps, frames, or
interleaves ANSI sequences with a token, the masker silently produces unmasked output. Register a regex
derived from the documented prefix grammar (something like
ghs_[A-Za-z0-9_-]{20,}) so any string of that shape gets redacted, byte-exact match or not. - Coordinate disclosure with the major package managers before the next rollout wave. Composer, npm, pip, RubyGems, Cargo all have token-handling paths that look similar to the one that leaked here. Reach out before the next batch of repos flips. A two-week heads-up to ecosystem maintainers would cost Github very little and prevent a repeat.
- Automate testing of the most common Github actions setups for the top 50 programming languages.
What other ecosystems have to do
The class of bug ("downstream validator rejects a token the issuer reformatted, and prints the rejection") is not Composer-specific. Anyone with a package manager or installer that handles Github auth should:
- Grep your codebase for hardcoded Github token regexes. Any pattern sourced from the 2021 changelog. If you find one, ideally delete it entirely. The validator does very little for DevX and the rejection path was the one that bit PHP.
- Never interpolate a rejected credential into a user-visible error. This is the actual root
cause on the downstream side. Even if Composer's regex had permitted
-, the same primitive (validate → reject → throw with the candidate value in the message) would fire on the next undocumented format change Github (or any other token issuer) ships. The fix is structural: redact-or-omit credential-shaped values before they reach an exception message, a log line, or a stack trace. - Audit other credential paths the same way. Validators that reject "weird characters" tend to be older code. Where you find one, check what else the same module logs, prints, or stack-traces. The Github token is one example of the pattern; it isn't the only credential routed through these paths.
- Reach out to Github Security ahead of the next rollout. If you maintain a package manager that pipes a Github credential into anything that could throw with the value attached, get on Github's notification list before the next batch of repos flips. Even a few days of warning is enough to ship a validator fix ahead of the wave.
A note on disclosure
I want to spend a couple paragraphs on how this felt from the reporter's side. Not as a complaint, but as a description of a structural problem someone with power should think about.
The model for responsible disclosure assumes there is one vendor on the other end, with a security team, a queue, and a person paid to read the queue. Most of the time that's exactly how it works. You write up the bug, you submit it, they triage, they patch, you go home.
This one didn't have that shape. The root cause was at Github. The fix that mattered most quickly was at Composer. The blast radius was every PHP framework maintainer on Earth (with packages on Github), and we have no shared security inbox. There was no way to reach all of them, on a Tuesday evening, in under an hour, as someone they've never heard of. Every minute the leak went undetected was a minute an attacker with a scraper could have been turning failing builds into push-to-tag access.
So the "responsible" path and the "fast" path diverged. The responsible path was: file with the Composer security advisory queue, file a HackerOne against Github, wait. The fast path was: open Discord, ping modmail, open X, find someone who knows the people you need, cold-call a colleague, and hope you're taken seriously by people who have no reason to know who you are.
I picked both paths. I'm still not sure I was right. I'm fairly sure that traversing the fast path was less wrong than waiting for the advisory queue to process would have been. What bothers me is that someone less well-connected than I am, with the same bug at 10pm on a Tuesday, doesn't have a faster lever to pull than "guess who knows the Taylor Otwell's" of the PHP ecosystem.
If you're reading this and you have power in this picture (at Github, at HackerOne, at one of the major ecosystem package managers), I would like for there to be a phone number. A pager. A shared cross-vendor channel for "this is an ongoing ecosystem-scale leak and I need an adult." I don't know what that looks like in detail. But "submit a form and pray" is not adequate for incidents that look like this one, and I don't think we should keep relying on the fact that the next reporter happens to have my network, albeit small as it is.
Especially in the modern era of AI as the "discovery to abuse" window tightens, time-to-response becomes more critical than ever before.
