For reporting a vulnerability, see the security policy. This is about running the thing safely.
- Never seed demo content on a live site. Those accounts have passwords
published in the README.
modulo:seed-demorefuses production without bothMODULO_DEMO=trueand--force, and the installer leaves the box unchecked. - Unique
APP_KEYper install. It encrypts sessions and cookies. Never copy one between environments. APP_DEBUG=false,APP_ENV=production. Debug mode exposes configuration and stack traces.- Terminate TLS in front. The
webcontainer is plain HTTP by design. SetAPP_URLto thehttps://address so signed URLs and password-reset links are generated correctly. - Treat
install pluginsas shell access. A plugin is arbitrary PHP running as the application. It is not sandboxed and cannot be.
Worth knowing so you do not re-solve it:
- Only
/index.phpexecutes PHP. A.phpfile anywhere else — including an uploaded one under/storage— returns 404. Carry this into any web server config you write yourself. - Dotfiles are not served, so
.envis not readable over HTTP. - Post content is sanitised server-side before rendering, so an editor cannot plant a script that runs for an administrator.
- Uploads are checked by extension as well as MIME type and stored under a generated filename.
- Privilege escalation is blocked: a non-super-admin cannot edit the
super-adminrole, rename system roles, or grant permissions they do not already hold. - Guest orders need an unguessable token, not just an order number.
- Sessions are invalidated elsewhere when a password changes.
- Plugin packages are checksum-verified against the registry before extraction, and extraction refuses traversal entries, symlinks, oversized archives and unexpected file types. See plugins.md.
Said plainly, because the gap matters more than the list above:
- Plugin packages are checksum-verified, not signed. The registry is the only authority; whoever controls it controls what installs.
- Packages contain executable trusted code. Checksum verification does not establish that a publisher's code is safe; install extensions you trust.
Container scans retain distribution advisories even when no stable package fix exists. Earlier Debian-based images included CVE-2026-6653 in libxml2, listed by Debian as a postponed parser denial-of-service issue. SVG uploads accept UTF-8 only and reject DTD/entity declarations before XML parsing, limiting exposure to that internal-subset parser path. This mitigation does not mean the underlying package is patched. PHP images now use Alpine, with OS packages upgraded during builds. Keep rebuilding supported base images as fixes arrive; CI retains the full report and gates every fixable high/critical finding.
MODULO_DEMO=true restricts server administration even for the shared super-admin
account. Visitors can edit content, products, media and validated theme appearance
settings. Backup administration,
extension installation/removal, users/roles, credentials (including public
password-reset requests and token redemption), API tokens, and mail or
payment configuration are blocked on the server. Operators use private CLI access.
The web installer requires a one-time token from php artisan modulo:install-token.
Only its SHA-256 hash is stored. Tokens expire after one hour, are claimed by one
session, and disappear when setup finishes. --rotate replaces a lost or expired
claim. Setup and first-administrator creation are serialized using locks on the
shared storage volume. Replicas must share that volume during setup.
Core and plugin admin routes use the admin.access middleware group. Older plugin
routes beneath /dashboard also receive mandatory two-factor enrollment checks.
When required, administrator API tokens are refused until their owner enrolls.
Setup, login, logout, and two-factor recovery stay accessible.
Publication permissions cover scheduling and publication-date changes, including date-only API updates. Editing the text of an already-public item remains allowed with its edit permission. Backup upload, restore, and download require a recently confirmed password on normal installations. Package downloads validate every redirect and pin public DNS answers; downloads and archive expansion are bounded.
CI fails on known advisories in locked dependencies, on every push:
composer audit
npm audit --audit-level=highDependabot raises updates weekly. Majors are grouped by toolchain, because packages with peer dependencies on each other cannot be upgraded one at a time.
The application sends these on every web response, so a bare-metal or tarball install is covered without web server configuration:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
SAMEORIGIN |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
camera, microphone, geolocation off |
Cross-Origin-Opener-Policy |
same-origin |
Strict-Transport-Security |
on HTTPS responses, 180 days (MODULO_HSTS_MAX_AGE, 0 = off) |
Content-Security-Policy is nonce-based: every inline script the core emits (Ziggy
routes, the plugin import map, Google Tag Manager/Analytics) carries a per-request
nonce, and 'strict-dynamic' trusts what those load (Vite chunks, plugin bundles).
MODULO_CSP is report by default — violations only appear in the browser console —
so you can check your site with your plugins and embeds, then set it to enforce.
Add sources per directive with MODULO_CSP_SCRIPT_SRC, MODULO_CSP_IMG_SRC,
MODULO_CSP_CONNECT_SRC, MODULO_CSP_FRAME_SRC, MODULO_CSP_STYLE_SRC,
MODULO_CSP_FONT_SRC (space-separated). MODULO_SECURITY_HEADERS=false turns all of
this off, for when a proxy in front sets them.
In production every password rule (registration, reset, change, installer, admin user
form) requires at least 12 characters (MODULO_PASSWORD_MIN_LENGTH) with letters and
numbers, and rejects passwords found in known breaches via the Have I Been Pwned range
API (MODULO_PASSWORD_UNCOMPROMISED=false to skip; only the first five characters of
the password's SHA-1 leave the server).
Every user can turn on authenticator-app codes under Settings → Two-factor authentication: scan the QR code, confirm with a code, save the eight recovery codes. Turning it on, off, or regenerating recovery codes asks for the password again.
At login, a correct password for such an account does not sign in; the user is asked
for a code (or a recovery code, each usable once). Codes are standard TOTP (RFC 6238,
SHA-1, 6 digits, 30 s, one step of clock drift), a code is never accepted twice, and the
challenge locks for a minute after five wrong codes. Secrets and recovery codes are
stored encrypted with APP_KEY.
MODULO_REQUIRE_2FA_FOR_ADMINS=true sends users with the admin or super-admin role
to set it up before they can use the dashboard or admin area, and stops them turning it
off.
A user who has lost both their device and recovery codes can be reset by an operator:
php artisan tinker --execute="App\\Models\\User::where('email', 'someone@example.com')->first()->forceFill(['two_factor_secret' => null, 'two_factor_confirmed_at' => null, 'two_factor_recovery_codes' => null])->save();"Settings → Activity (administrators only) is the audit trail: sign-ins, failed and locked-out sign-ins, wrong two-factor codes, password resets; creating, changing and deleting posts, pages, users, roles, post types, taxonomies and menus; role and permission assignments; settings saved; plugins and themes installed, activated, updated and removed; two-factor turned on or off; backups created, downloaded, deleted and restored; and core upgrades. Each entry records who, when, the IP address and user agent, and what changed (attribute names, never values of passwords, tokens or 2FA secrets). Filter by area, by user, or search descriptions.
Entries older than MODULO_ACTIVITY_RETENTION_DAYS (180) are pruned daily at 04:30
(php artisan model:prune --model="App\Models\Activity"); 0 keeps everything.
Plugins can add their own entries:
App\Support\ActivityLog::record('my-plugin.exported', 'Exported 120 orders', $model, ['format' => 'csv']);