Blog

Composer Supply Chain Attack Checklist for PHP Projects

Attackers hit Packagist three times this spring with stolen GitHub access. Here's what happened, what Composer changed, and the checklist I use to lock down a PHP project's dependencies.

Caleb Dorris 22 October 2026, 12:00 PM Updated 30 October 2026 11 min

Cover reading "Lock Down Composer" with a Security tag: a warehouse shelf of shipping crates, one chained shut with a padlock and wax seal beside a pried-open crate glowing with a suspicious package.

Update, October 29, 2026 (revised October 30)

Composer 2.11 and 2.2.31: security fixes, and the cooldown is here

On October 29, Composer shipped 2.11.0 and 2.2.31, the long-term support release for projects stuck on PHP 5.3 to 7.1. Both fix two published security advisories and a handful of smaller bugs. 2.11 also ships the cooldown policy I mention below, and removes source fallback for good. On October 30, 2.11.1 followed with bug fixes, so that's the version to install.

What got fixed

  • API tokens could end up in composer.lock (GHSA-fm2w-xgf2-f9fh, rated medium, CVSS 5.3). Some private repositories are set up with an inline HTTP header (options.http.header) that carries a token, often in your global Composer config. Composer copied that header into composer.lock, which most teams commit. Credentials kept in auth.json or COMPOSER_AUTH were never written there.
  • A tampered installed.json could reach outside vendor/ (GHSA-c968-33g3-wh7p, rated high, CVSS 7.1). A crafted package name in vendor/composer/installed.json could make composer install delete a directory outside vendor/, and composer reinstall write files there. Composer says publishing a package can't trigger it. An attacker has to be able to write that file. The realistic case is a CI cache of vendor/ that untrusted jobs, like pull requests from forks, can also write to.
  • Smaller fixes. Extra arguments passed to @composer scripts weren't escaped. Repository headers could be sent to other hosts whose names merely start with your repository's host. And Composer now rejects the compromised eMudhra intermediate certificates even if your system trusts their roots. That's the same certificate problem behind the WordPress 7.1.4 and Python updates in my October security patches roundup. On 2.2.31, that fix needs composer/ca-bundle 1.4.16 or later.

What to do this week

  1. Upgrade everywhere Composer runs. composer self-update gets you 2.11.1. Skip 2.11.0: it can abort every composer update with a SecurityException when a PHP extension reports a name that isn't a valid platform package name, and 2.11.1 fixes that. It also fixes GitLab CI job tokens failing against GitLab's Composer package registry, plus a PHP 8.6 deprecation warning. On the LTS line, composer self-update --2.2 gets 2.2.31. It has the same update bug, and the fix is merged but hadn't shipped in a 2.2 release as of the morning of October 30. If Composer came from a distro package, update it there. Then check composer --version on laptops, CI runners and deploy servers.
  2. Check your lock files for leaked tokens. Look for header entries under transport-options in composer.lock, in the current file and in git history, for example with git log -p --all -- composer.lock | grep '"header"'. After upgrading, composer update --lock rewrites the lock without them. If a token shows up anywhere it was shared, revoke and rotate it. Deleting it from the latest commit doesn't remove it from history. My API key rotation checklist covers the order.
  3. Don't trust a shared vendor/ cache. If CI restores vendor/ from a cache that fork PRs or other low-trust jobs can write to, scope that cache to trusted jobs or rebuild with composer install.
  4. Check for source-fallback. It was a temporary option in 2.10, and 2.11 removed it. A failed dist download now just fails instead of cloning the repo. If you really need source installs, ask for them with preferred-install or --prefer-source.
  5. Consider a cooldown. It's off by default. To hold back anything published in the last day, add this to composer.json: {"config": {"policy": {"cooldown": {"period": "1 day"}}}}. Composer's team suggests a few hours to a day, because security fixes wait out the cooldown too. There's no automatic exemption. For a fix you need now, run composer update --no-cooldown vendor/package. Put your own packages under ignore. If your project already has a policy config, expect a lock-file-outdated warning after upgrading until you update the lock.

If you're already patching PHP itself for PHP2Shell, do Composer in the same pass. If you'd like a second pair of eyes on a PHP or WordPress project's dependencies, get a free quote.

For years, a Composer supply chain attack was something PHP developers read about happening to npm. That changed in 2026. Between April 30 and May 23, attackers used stolen GitHub access to push malware into real, widely installed Packagist packages, not typosquats.

This week's PHP2Shell vulnerabilities were about the PHP runtime itself. This is the other half of the picture: the code you pull in with composer require. I spend most days in PHP projects with a vendor/ folder full of other people's code, so here's what happened, what Composer and Packagist changed, and the checklist I actually run.

What happened to Packagist in 2026?

Three incidents stand out, and each one used a different way to get code running.

intercom/intercom-php, April 30. The attacker changed the 5.0.2 tag to point at a new commit. The poisoned version turned the API client into a Composer plugin that ran on post-install-cmd and post-update-cmd, downloaded the Bun runtime, and ran an obfuscated credential stealer. Socket says it flagged the code 14 minutes after release, and Packagist pulled it. Intercom traced the root cause to a compromised Python package installed on one machine, which shows how these attacks hop between ecosystems.

laravel-lang, May 22 to 23. A leaked GitHub token let the attacker rewrite historical release tags across four laravel-lang/* packages. Snyk counts about 700 poisoned versions. The payload was a src/helpers.php file registered under autoload.files, so it ran on every PHP request, not just at install. It went after cloud keys, .env files, SSH keys and CI secrets.

Eight dev-branch packages, May 23. These hid a postinstall script in package.json, not composer.json, aimed at projects that run npm build steps next to their PHP code. Every affected version was a dev-* branch.

The pattern is the same in all three: nobody broke Composer. They took over a maintainer's GitHub access and published something that looked legitimate.

What changed after the Composer supply chain attacks

Packagist and the Composer team moved fast.

  • Stable versions are now immutable on Packagist.org. If someone re-tags a published version upstream, Packagist rejects the change and emails the maintainers. That's the exact trick the intercom and laravel-lang attacks used.
  • Composer 2.10, released May 28, blocks known malware by default. Packagist imports malware flags from Aikido. Flagged versions can't be installed by update or require, and they fail composer install even if they're already in your composer.lock. composer audit fails on them too. You don't need to configure anything.
  • Source fallback is off. If a dist download fails, Composer 2.10 no longer quietly falls back to cloning the git repo, which could have pulled a tampered tag. Composer 2.11 removed the temporary source-fallback option that turned it back on.
  • A cooldown policy shipped in 2.11. It holds back versions published in the last N hours or days, since most malicious releases are caught within that window. Composer 2.11.0 came out October 29. The cooldown is off until you set a period. See the update at the top.

That's real progress. It also means a lot of protection depends on whether your servers and CI are actually on a current Composer.

How do I protect my PHP project from malicious packages?

Here's my checklist. None of it is exotic, and most of it takes an afternoon.

  1. Update Composer everywhere. Run composer --version on your laptop, CI runners and every server that deploys code. Anything older than 2.10 doesn't get the malware policy, and anything older than 2.11.0 (or 2.2.31 on the LTS line) misses the October 29 security fixes. Go straight to 2.11.1, which fixes a bug in 2.11.0 that could stop composer update cold. composer self-update fixes it, unless the server uses a distro package, in which case update it there.
  2. Commit composer.lock and deploy with composer install. Production should never run composer update. The lock file records exact commits and is what makes a build reproducible.
  3. Read lock file diffs. When a PR changes composer.lock, look at what moved. A patch release of a localization package that suddenly adds an autoloaded file is the kind of thing a human notices.
  4. Stay off dev-* branches. Keep minimum-stability at stable. Dev branches are mutable by design, so immutability doesn't cover them, and the cooldown policy won't either. All eight packages in the May 23 campaign were dev branches.
  5. Keep an explicit allow-plugins list. Composer only runs plugins you've allowed. An API client that wants to become a plugin, like the poisoned intercom package, should never be on that list.
  6. Run composer audit in CI. Since 2.10 it fails on malware and on packages with security advisories. Make that failure stop the build.
  7. Don't forget the JavaScript side. If your PHP project runs npm install, those packages can run lifecycle scripts too. In CI, npm ci --ignore-scripts is a reasonable default if your build doesn't need them.
  8. Limit what installs can reach and read. Build steps shouldn't have production secrets in their environment or open internet access to anywhere. Snyk points out that an egress allowlist would have stopped the laravel-lang payload's second-stage download.
  9. Turn on the cooldown. It shipped in Composer 2.11. Composer's team suggests a few hours to a day, since a longer period also delays security fixes. Use --no-cooldown for a fix you need right away, and ignore for your own packages.
  10. If you were exposed, rotate everything. If a host installed an affected version, assume every secret the PHP process could read is gone: database passwords, API keys, OAuth client secrets, cloud keys. Rebuild the box, don't clean it.

The caveats

Lock files don't freeze time. A composer install reproduces the lock, but the next composer update rewrites it with whatever the registry says now.

Scanners lag. Version numbers alone couldn't prove whether a laravel-lang install was affected, because the bad tags were later pointed back at clean code. Check install dates and indicators, not just the current version.

Your own accounts count. If you publish packages, turn on MFA on GitHub and Packagist. Packagist says mandatory MFA is coming.

That's not a reason to stop using open source. It's a reason to know what's in vendor/ and how it got there.

Where AI agents fit in

If an agent can run shell commands in your repo, it can run composer require. I let agents do a lot of the typing in my AI coding setup, but new dependencies get a human look before they're committed. An agent will happily add a package because a blog post mentioned it, and it won't check who maintains it.

What I still do by hand

The tooling now handles known malware and re-tagged versions. What it can't do is decide whether a package belongs in your project at all. Fewer dependencies means fewer maintainers you're trusting with your production secrets.

Need a hand?

Want your PHP dependencies checked?

Current Composer (2.11.1 as of October 30) blocks known malware, and Packagist now refuses re-tagged stable versions. That only helps if every laptop, CI runner and server actually runs it. What no tool can decide is whether a package belongs in your project at all. That takes a human reading lock file diffs, and I'm a little too happy doing it.

How I handle it:

  1. Find every Composer and every lock file. Versions on laptops, CI and deploy servers, plus a look for leaked tokens in composer.lock and its history.
  2. Tighten the setup. Deploys from a committed lock with composer install, minimum-stability at stable, an explicit allow-plugins list, composer audit in CI, and a sensible cooldown.
  3. Trim and review. Packages you don't need come out, and lock file diffs get read before they're merged.

The aim is fewer surprises in vendor/ and a build you can trust. I can't promise a package you depend on will never be compromised, but I'll tell you what I'd tighten first and why. If you have a PHP or WordPress project that hasn't had its dependencies looked at in a while, get a free quote.

Caleb Dorris, senior software developer in Nashville

FAQ

What is a Composer supply chain attack?

It's when an attacker gets malicious code into a PHP package you install with Composer, usually by taking over a maintainer's GitHub or Packagist access. The code then runs on your machine, in CI or in production when the package installs or loads.

Does Composer block malware now?

Yes. Since Composer 2.10, released May 28, 2026, versions that Packagist has flagged as malware can't be installed through update or require. They also fail composer install from an existing lock file, and composer audit fails on them by default.

Is composer.lock enough to protect me?

It helps a lot, but it isn't enough on its own. It pins exact commits for composer install, but composer update refreshes from the registry. It also does nothing about a bad version you locked before anyone flagged it, unless you're on Composer 2.10 or later.

What is the Composer cooldown policy?

It's a setting that holds back package versions published within a period you choose, like 7 days, during composer update and require. It shipped in Composer 2.11.0 on October 29, 2026, and stays off until you set a period. Private Packagist offers an organization-wide version.

Sources

Need a web developer in Nashville?

PHP, WordPress and Next.js. Free quote, and I usually reply within a couple of hours.

caleb@calebdorris.com and call or text (615) 914-1073

All posts