Blog

Verify your Composer dependencies' provenance

When the xz backdoor landed in 2024, it was a wake-up call for every package ecosystem: the code you install is only as trustworthy as the pipeline that built it. PHP is no exception. Run composer install and you pull down code from dozens of repositories — but nothing checks who built each package, or whether the archive you received is the one its maintainer actually published.

composer.lock doesn’t solve this. It pins a dist hash, so it verifies you got the same bytes every time — but it says nothing about where those bytes came from. If an attacker publishes a malicious release, the lock file faithfully pins the malicious hash. Integrity is not provenance.

The rest of the software world has an answer to this now: Sigstore and build-provenance attestations. npm ships Sigstore-signed provenance. GitHub Actions can attest any build artifact, recording a signed statement — “this artifact was built by this workflow in this repository” — in a public transparency log. Until recently, PHP had no way to produce or consume any of it.

This post shows the whole loop working end to end, on real packages.

The one subtlety that matters: attest the zipball

Here’s the trap that makes naïve provenance for Composer silently useless.

Most “sign your release” setups attest a release tarball — the output of git archive, uploaded as a GitHub release asset. But Composer doesn’t install that. It installs the dist zipball: api.github.com/repos/{owner}/{repo}/zipball/{commit} — a different artifact with a different digest. Attest the tarball and your attestation covers a file nobody installs. A verifier checking what Composer actually downloaded finds nothing.

The fix is to attest the exact zipball Composer fetches. Its digest is reproducible for a given commit, and — crucially — the commit is the same reference Packagist records as the package’s dist. Attest that, and the attestation covers the bytes that land in vendor/.

Signing: one line in your release workflow

k2gl/composer-attest-action is a GitHub Action that does exactly this — it attests both the tarball and the dist zipball. Drop it into a workflow that runs on version tags:

name: Attest
on:
  push:
    tags: ['[0-9]*.[0-9]*.[0-9]*']
permissions:
  id-token: write        # request the Sigstore signing certificate
  attestations: write    # record the attestation
  contents: write        # attach signed assets to the release
jobs:
  attest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { persist-credentials: false }
      - uses: k2gl/composer-attest-action@v1

On every version tag it builds and attests the tarball, fetches and attests the Composer dist zipball, and records both in Sigstore’s public transparency log — bound to your repository’s GitHub Actions identity. No keys to manage; the signing certificate is short-lived and issued against your workflow’s OIDC token.

Verifying: at install time

k2gl/composer-attest is a Composer plugin that verifies these attestations as packages are downloaded:

composer require --dev k2gl/composer-attest

As Composer downloads each package, the plugin hashes the dist, asks GitHub for an attestation bound to that digest, and verifies the Sigstore bundle — checking the certificate chain, the transparency-log inclusion, and that the signing identity is a GitHub Actions workflow of the package’s own repository. Configure how strict it is in composer.json:

{
  "extra": {
    "k2gl-attest": {
      "mode": "enforce",
      "require-attestation": false
    }
  }
}

In warn mode it reports and continues; in enforce it fails the install on a bad attestation. Under the hood it reuses k2gl/sigstore-verify, a pure-PHP Sigstore verifier that passes the official sigstore-conformance suite.

Does it work? Here’s the proof

The entire k2gl package family attests its dist zipball on every release. Verifying one against the live GitHub attestations API:

dsse zipball — hasAttestation: yes, verified: YES (k2gl/dsse)

The plugin even verifies itself: composer-attest’s own release is attested by composer-attest-action, and the plugin confirms its own provenance. The loop is closed — sign with the Action, verify with the plugin, on real published packages.

The honest caveat

The verification is real, but adoption is a chicken-and-egg problem worth stating plainly. Today almost no package on Packagist attests its dist zipball, so for a typical project the plugin will mostly report “no attestation.” That’s not a bug in the plugin — it’s the state of the ecosystem. The plugin verifies whatever is attested; the more maintainers add the Action, the more it can check.

That’s why the Action matters as much as the plugin: it makes attesting a one-line change, so the pool of verifiable packages can grow. The endgame is registry-level support — if Packagist hosts attestations directly, client-side verification becomes the default for the whole registry, and these tools verify it with no changes.