Laravel Badge

Guide

Securing Your Laravel Packages with Laravel Moat

By Peter Fox

Three days before the Laravel-Lang supply chain attack, Nuno Maduro released Laravel Moat. That timing probably wasn't a coincidence.

This guide covers what Moat is, why it exists, and how to set it up if you maintain any public PHP package.


The supply chain crisis nobody took seriously enough

For years, open source security researchers warned that the package ecosystem was a prime target. Most maintainers acknowledged it and moved on.

Then, in 2018, the event-stream npm package (downloaded 1.5 million times per week) was silently handed to a malicious actor. The new "maintainer" added a dependency whose payload was hidden in the minified npm upload, invisible in the GitHub source. It targeted a Bitcoin wallet. The package's own description string was the AES-256 key to decode the attack.

In March 2024, xz-utils raised the stakes. A state-linked actor spent two and a half years building credibility as a legitimate maintainer of the xz compression library before slipping a backdoor into the release tarballs in versions 5.6.0 and 5.6.1. The backdoor enabled SSH remote code execution and was scored CVSS 10.0 (a perfect ten). It was caught almost by accident when a Microsoft engineer noticed that sshd login times were slightly slower than expected on a bleeding-edge Debian build.

In June 2024, the polyfill.io CDN domain was acquired by a Chinese company. Within months it was injecting malicious JavaScript into responses for over 380,000 websites, including JSTOR, Intuit, and the World Economic Forum. The original library author had been warning against using the CDN for years.

All of these attacks exploited the same thing: trust in the distribution channel. The code that arrived on your machine was not the code the author wrote.

What happened to Laravel-Lang

On 22 May 2026, an attacker with a leaked GitHub Personal Access Token rewrote every git tag across four widely used Laravel localisation packages in under 90 minutes:

  • laravel-lang/lang (502 tags)
  • laravel-lang/http-statuses
  • laravel-lang/actions
  • laravel-lang/attributes

GitHub allows a version tag to point to a commit in a fork of the same repository. The attacker created a fork under their control with malicious commits, then rewrote the official organisation's tags to point at those commits. On Packagist, nothing looked wrong: the version numbers were unchanged, the repository URLs were unchanged. The packages appeared entirely legitimate.

The payload registered itself via autoload.files so it ran automatically on every PHP application boot. It fetched a credential stealer from a typosquatted domain and harvested AWS and GCP keys, SSH keys, Kubernetes and Vault secrets, CI/CD tokens, .env files, browser passwords from 17 Chromium variants, and cryptocurrency wallets. Data was AES-256 encrypted and exfiltrated before the stealer deleted itself.

Worth noting: these are third-party community packages, not the official Laravel framework or packages maintained by Laravel Holdings. But they are extremely widely used across the ecosystem.

Packagist delisted the affected packages as soon as it was notified. GitHub has since restored the repositories. Any application that ran a composer install or composer update during that 90-minute window was potentially compromised.

Enabling one GitHub setting would have stopped this before it started.

Introducing Laravel Moat

Laravel Moat is a read-only security auditing CLI for your GitHub organisation and repositories. It was created by Nuno Maduro (the engineer behind Pest, Larastan, Pint, and PHP Insights) and lives under the official Laravel GitHub organisation.

Moat connects to GitHub's API using your existing credentials and reads your organisation's security configuration. It produces a list of what you have and haven't enabled. Nothing is changed or remediated; it just shows you what's missing.

The tool performs 22+ checks covering the major attack vectors seen in real incidents:

  • 2FA enforcement across your organisation and all members
  • Branch protection on your release branches
  • Immutable releases (the single most direct countermeasure to the Laravel-Lang attack)
  • Signed commits
  • Secret scanning and push protection (blocks secrets at commit time)
  • Dependabot alerts and automatic security updates
  • Pinned GitHub Actions (actions referenced by commit SHA, not mutable tag)
  • Workflow permissions (read-only by default)
  • pull_request_target safety (a common misconfiguration that can expose secrets to fork PRs)
  • Repository webhooks (HTTPS + shared secret verification)
  • Direct collaborators (team-based access only)
  • Private vulnerability reporting (coordinated disclosure)
  • SECURITY.md presence
  • Dependabot config for keeping pinned action SHAs up to date

A clean Moat report does not certify that you are secure. A failing report does not mean you have been compromised. But it tells you exactly where the gaps are.

Why immutable releases matter

The Laravel-Lang attack was possible because git tags are mutable by default. A tag is just a pointer to a commit. Anyone with push access can delete a tag and recreate it pointing elsewhere. Packagist's verification doesn't catch this; it trusts whatever the tag points to.

GitHub's Immutable Releases feature, which became generally available in October 2025, changes this. When you enable it, publishing a release locks the git tag to a specific commit SHA. The tag cannot be moved, deleted, or reused after publication. Each release also receives a cryptographic attestation in the Sigstore format, containing the release tag, the commit SHA, and hashes of the release assets.

The Laravel-Lang attack, had it been attempted against a repository with immutable releases enabled, would have failed at the first step. You cannot rewrite an immutable tag.

Moat's repositories_releases_are_immutable check tells you whether you have this enabled. For most repositories right now, it isn't.

Setting up Moat

Moat is a Rust binary with no composer require. Install it via Homebrew:

brew tap laravel/moat https://github.com/laravel/moat
brew install laravel/moat/moat

Or download a prebuilt binary from the releases page and add it to your PATH.

Moat authenticates using your GitHub token. It will use the GITHUB_TOKEN or GH_TOKEN environment variable, or fall back to your GitHub CLI login (gh auth token).

For auditing an organisation, your token needs admin:org, repo, and workflow scopes. For a personal account, repo and workflow are sufficient.

Running an audit

# Audit a whole organisation
moat your-org-name

# Audit a single repository
moat owner/repo-name

# Audit your own account
moat your-github-username

Moat produces a colour-coded report listing each check as PASS, FAIL, or SKIPPED, with a count of failing repositories at the end.

Silencing inapplicable checks

Not every check makes sense for every repository. A proof-of-concept package that never tags releases doesn't need repositories_releases_are_immutable. Place a moat.toml at the repository root to skip specific checks:

[checks]
repositories_commits_are_signed = "off"
repositories_releases_are_immutable = "off"

release_branches = ["1.x", "2.x"]

Checks set to "off" render as SKIPPED and don't count toward failures. The release_branches key tells Moat which branches to treat as protected release branches for the locking and linear-history checks.


What to fix first

If you maintain a package, here is a sensible order of priority:

1. Enable immutable releases. Go to your repository settings, Code security and analysis, and enable Immutable releases. This is the highest-impact change and takes about 30 seconds. It directly prevents the tag-rewriting attack that hit Laravel-Lang.

2. Require 2FA across your organisation. Every collaborator with push access should have 2FA enabled. A compromised password without 2FA was the entry point for the Laravel-Lang attacker. Organisation Settings, Authentication security, Require two-factor authentication.

3. Enable secret scanning and push protection. GitHub will scan your commits for secrets (API keys, tokens, credentials) and optionally block pushes that contain them. Settings, Code security, Secret scanning, Push protection.

4. Pin your GitHub Actions. Actions referenced by mutable tags (actions/checkout@v4) can change without your knowledge if the tag is moved. Pin to a commit SHA and use Dependabot to keep them updated:

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

5. Lock release branches. Prevent force-pushes to your main and release branches. Settings, Branches, Branch protection rules, Require linear history, Do not allow bypassing the above settings.


The Laravel ecosystem sets the standard

Moat is a good example of something the Laravel ecosystem does consistently well: practical tools that solve real problems without much ceremony.

It's not a compliance checklist or a lengthy advisory. It's a compiled binary that runs in seconds and tells you what to fix. Nuno Maduro has a track record of doing exactly this — pest, pint, phpinsights — taking something that felt like a burden and making it feel effortless.

The Laravel-Lang attack hit real people who maintain packages in their spare time, for free, used by hundreds of thousands of applications. They didn't fail some checklist. They had the same configuration that nearly every open source PHP maintainer has, because until recently there wasn't a clear, actionable alternative.

Whether or not the timing of Moat's release was deliberate, the effect is the same: the tool that could have prevented the attack now exists, it's free, and it runs in under a minute.

You still have that option. The Laravel-Lang maintainers did not.

Run Moat against your organisation. Enable immutable releases first — it's one checkbox and it directly closes the attack vector that caused the incident. Then work through the rest of the report at whatever pace makes sense.

Supply chain attacks succeed because they exploit the gap between "this is theoretically possible" and "someone has actually done it to us." That gap is now closed for the Laravel ecosystem. Make sure it's closed for your packages too.