Security
Reporting a vulnerability
Email [email protected]. Please do not open a public issue.
We aim to acknowledge within two working days, and to publish an advisory once a fix is available or after 90 days, whichever comes first. If something is being actively exploited we move faster and say so.
What Manager for Craft can and cannot do
The point of the design is that these are properties of the code rather than promises.
| No administrator password | There is nowhere in the schema to store one. A test walks the live schema for anything resembling a stored site credential, so a new table is checked the moment it is created. |
| No SSH or database credentials | Same. |
| No inbound instruction to managed sites | The connector registers no route that accepts management input. It answers one anonymous endpoint whose entire vocabulary is "check in now" - it reads no parameters, takes no body, and only causes the site to make the ordinary signed request it makes on its own timer. Every exchange that decides anything is still started by the connector, outbound, and nothing depends on that endpoint being reachable. |
| No remote execution | No console-command runner, no PHP evaluation, no SQL, no shell, no arbitrary file access. |
| Read-only by default | A newly paired site receives read capabilities and nothing else. Anything that modifies a site needs separate, explicit confirmation. |
| Public keys only | Connectors generate their keypair locally and send only the public half. A stolen copy of the platform database confers no ability to sign as any site. |
How a connector authenticates
Every request carries the site identifier, connector version, timestamp, a random nonce, the method, the canonical path and a hash of the body - all of it covered by an Ed25519 signature. Change any of those and the signature stops verifying.
On receipt the platform enforces, in order: a payload size cap before parsing, required headers, a clock tolerance of ±120 seconds, a site and connector lookup, signature verification, per-site rate limiting, and finally a replay check.
The replay check comes last, only once a request has proved authentic, so that nobody who can merely reach the endpoint can fill the replay store. It fails closed: if the store is unreachable the request is rejected with a 503, because accepting it would mean accepting replays of it.
An unknown site and a bad signature return an identical response through the same code path, and an unknown site is verified against a decoy key so the timing matches too. Otherwise the endpoint would be a way to enumerate which sites exist.
Responses carrying commands or security-sensitive configuration are signed by the platform over a string that includes the request nonce, which binds a response to the one request that asked for it.
What a connector may report
An allowlist, enforced by a shared schema. Unknown fields are rejected, not stripped: silently discarding them would let a connector widen what it collects without anyone noticing.
Permitted: Craft version and edition, PHP version, database engine and version, plugin handles and versions, Composer package names and versions, connector version, a handful of safe configuration booleans, queue and migration counts, locally-computed licence state, and an environment classification.
Also permitted, each behind a capability of its own that must be granted deliberately:
| What | Capability | Detail |
|---|---|---|
| Disk usage | runtime:read | Byte and file counts per asset volume, by handle, plus free and total space on the volume. Never a path, a file name or a directory listing - a byte count says how much is there and nothing about what. A volume that cannot be walked inside the time budget, or that lives on remote storage, is reported as unmeasured rather than as empty. |
| PHP limits | runtime:read | Numeric limits: memory, execution time, upload and post size, input vars, opcache state and memory, and a count of loaded extensions. Never phpinfo(), never an ini path, never the list of extensions, and never a setting whose value would name the host. |
| Response times | runtime:read | Mean, median, 95th percentile and slowest, sampled from up to 200 requests the site was serving anyway. A duration and nothing else: no URL, no visitor, no address, no user agent. This is server render time, not time to first byte - it excludes DNS, TLS, queueing in front of PHP and the network - and the interface says so wherever it appears. |
| Failed sign-ins | logins:read | Four counts and one timestamp: attempts, accounts affected, accounts locked out, and how many of those are administrators. Never a username, an email address, a user id or a source address. Read from Craft's own counters rather than by logging attempts, because a record of who tried to sign in as whom is a log of real people's behaviour on somebody else's website. |
The sign-in counts carry a caveat that is repeated on every screen showing them: Craft resets an account's failed-attempt counter on a successful sign-in, so the totals are a floor, not a total
- somebody who eventually guessed correctly leaves nothing behind in them.
Never: entries, assets, user records, password hashes, sessions, complete logs, environment-variable values, security keys, licence keys, API credentials, database credentials, complete configuration files, arbitrary file contents, file names, filesystem paths, request URLs, visitor addresses, or the identity of anybody who signed in or failed to.
A rejected payload is never stored, not even to help debugging - a report that failed validation is precisely where forbidden data would be. Only the field paths are recorded.
To see what a given site would send:
php craft manager-connector/previewThe audit log
Append-only, enforced two ways. A database trigger rejects UPDATE, DELETE and TRUNCATE, which holds even for the table owner; and deployments are documented to connect as a role without those privileges. manager:doctor warns if Manager for Craft connects as a superuser, because a superuser bypasses privilege checks entirely.
Events are hash-chained per organisation, so anyone who does get past both still cannot alter history without leaving a detectable break:
php artisan manager:audit:verifyWorth running on a schedule and after every restore: restoring an older backup is otherwise indistinguishable from deliberate truncation.
Secrets never reach the log. A guard screens every payload and throws rather than redacting, because a silent redaction would let a call site keep passing a password indefinitely with nobody noticing.
Account security
Password plus TOTP, with hashed single-use recovery codes. A user with a second factor is not signed in by the password step: their identifier is parked in the session and the session is only upgraded once the factor is satisfied, so no window exists in which one factor was enough.
Sensitive actions need the password to have been confirmed within the last fifteen minutes: changing capabilities, revoking a connector, disabling a second factor, reading out fresh recovery codes, deleting a site, deleting a backup, and shortening how long backups are kept. A session left open on an unlocked machine is not enough.
The rule is narrower than "anything that writes", and deliberately so: the gate is for changing what Manager is, not for using it. Adding a site, asking for a backup, setting a backup schedule, downloading an artifact and acknowledging a finding do not ask for a password. Each is authorised by role and audited, and each is something the person whose job this is does several times a week - a prompt in front of the routine work trains people to type their password without reading why, which costs more than it protects. What kept the gate is the half that destroys something, plus everything that changes who may reach the control plane or what a connector is permitted to do.
Resetting a password does not bypass the second factor, and it ends every other session.
A passkey satisfies the same requirement as TOTP, and is offered alongside it rather than instead of it. It is a second factor here, not a way past the password: an account is never signed in on a passkey alone. A single passkey on an unlocked, already-signed-in laptop is one factor, and this system can read every installation it manages.
That is enforced in three independent places, because a single mechanism is a single thing to get wrong:
- The authentication guard uses the plain Eloquent provider, so no code path anywhere resolves a credential other than a password into a session.
- The passkey package's own routes - including its passwordless login endpoint - are not registered.
- The challenge endpoint verifies the assertion against the user the password step named, and issues the session for that user rather than for whoever the credential resolves to.
Registration sits behind the same fifteen-minute password confirmation as any other sensitive action, and the last remaining second factor cannot be removed while the organisation requires one.
Note for operators: the WebAuthn user handle is derived from APP_KEY unless PASSKEYS_USER_HANDLE_SECRET is set. Rotating APP_KEY without having set that variable first changes every derived handle and orphans every registered passkey - an account with no authenticator app would be locked out. Set it explicitly before any key rotation.
Runbooks
A connector's key may have leaked
On the site: php craft manager-connector/disconnect - this deletes the key locally. Then revoke the connector in Manager for Craft, which stops the platform accepting anything signed with it. Both steps matter: the first stops the key being used from that server, the second stops it being used from anywhere.
Re-pair with a fresh enrolment code. The old key can never be reinstated.
A managed site may be compromised
Revoke the connector first - that stops the site reporting and removes its capabilities in the same transaction. Read the site's entries in the activity log for what it did while trusted. Since capabilities are read-only by default, a compromised site could not have used Manager for Craft to change anything.
A platform account may be compromised
Reset the password, which ends every other session. Review that account's activity-log entries. Check whether recovery codes were used, and regenerate them. If capabilities were changed, the permission history on each affected site shows exactly what and when.
The audit chain fails to verify
Treat it as an incident. Establish first whether a restore happened - that produces an intact but shorter chain, which is expected. A chain reporting altered events with no restore means somebody had enough database access to drop a trigger, and the appropriate response is a full compromise assessment, not a repair.
The platform signing key may have leaked
Generate a new one with manager:keys:generate --force. Every connector then rejects platform responses until it learns the new public key, which means re-pairing each site. Disruptive by design: the alternative is a platform whose instructions cannot be trusted.
Supported versions
The current minor release receives security fixes. The previous minor receives them for 90 days after being superseded.