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:

had a chance that the GITHUB_TOKEN (typically write-enabled) would be publicly logged to the Actions log on every build.

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:

github-action-log

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

  1. 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 likely github-actions[bot].
  2. shivammathur/setup-php automatically inserts that token into COMPOSER_AUTH as the github-oauth value.

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:

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:

  1. 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.
  2. GITHUB_TOKEN is repo-scoped. Even a successful exfil against laravel/framework doesn't get you anywhere near laravel/passport. Each repo's token can only act against that repo.
  3. 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.
  4. Branch protection blocks the obvious move. Pushing to main directly 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.
  5. 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.
  6. 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:

  1. Github's new secret format
  2. A workflow that pins composer to an unpatched version
  3. A workflow that runs on push, pull_request_target, or schedule (so the token is write-enabled by default)
  4. A step that pipes GITHUB_TOKEN into COMPOSER_AUTH (shivammathur/setup-php does this automatically when the env var is set)
  5. A composer invocation that triggers loadConfiguration() (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

  1. Upgrade Composer. Bump to 2.9.8, 2.2.28, or 1.10.28 (the three patched lines). If your workflows pin a Composer version, unpin it or bump the pin.
  2. Tighten permissions:. Don't rely on the legacy permissive default. Add an explicit permissions: block at the top of every workflow (or job) with the minimum scope each step needs. For most CI jobs that means contents: read and nothing else.
  3. 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.
  4. Sweep your Actions logs for the window. Look at any failure runs between 2026-05-12 ~22:00 UTC and 2026-05-13 ~14:30 UTC. If you see a ghs_… value in any rendered output. Review these jobs for composer pins.
  5. Check your tag and commit history for the window. Run git log --since='2026-05-12T22:00Z' --until='2026-05-13T14:30Z' --all and verify everything is yours. Same for git tag --sort=creatordate. Anything created in the window that you don't recognise warrants investigation.
  6. 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

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

This article was written by Damien Retzinger (https://github.com/damienwebdev) and originally published at https://github.com/graycoreio/github-actions-magento2/discussions/261.

We have republished it here with permission of the author to provide long-term reliable access to this content and a better URL to link to.