Security model
Scoped keys are only as good as the invariants underneath them. This page is the contract: the rules that hold no matter which scopes you pick, and why managed.dev chose them. They’re enforced at the authentication seam, not left to individual handlers.
The hard invariants
Section titled “The hard invariants”Every request is a triple intersection
Section titled “Every request is a triple intersection”Effective permission is always
perms(RBAC role) ∩ scopes(key) ∩ resource-constraint(key) — the key owner’s
team role, the scopes granted at mint time, and the
optional site pin. A scope can only subtract, so a key can never exceed the principal
that minted it. The full rule is in scopes.
Keys are forced non-admin — there is no admin scope
Section titled “Keys are forced non-admin — there is no admin scope”Every key authenticates as a client, unconditionally — even one minted by a platform admin. Platform admin is a property of an interactive human session, never of a delegable credential, so there is no admin scope to grant and no key can reach an admin-only surface. An admin who needs admin powers uses their browser session; their personal key deliberately can’t.
A side effect worth naming so you don’t file it as a bug: where a handler grants a human admin extra reach (for example, switching certain performance or WAF tiers), an admin’s key doesn’t inherit that. It’s intended — the key is a client, full stop. This is the clearest place managed.dev beats coarse host tokens: a Pantheon machine token is the whole account; a managed.dev key can never be admin at all.
Hashed at rest, shown once
Section titled “Hashed at rest, shown once”A key’s plaintext secret is shown once — at creation, and again only when a roll mints a new one — and only a SHA-256 hash is stored. A database compromise can’t reveal a live key, and there’s no plaintext column to leak. Lose a key and you roll or revoke and re-mint; you never recover it.
404-vs-403 existence hiding
Section titled “404-vs-403 existence hiding”The split is deliberate and ordered so a key can never confirm the existence of something it isn’t entitled to know about:
- A resource you can’t see →
404 not_found. If the key’s resource constraint excludes it, the API behaves as if it doesn’t exist. A narrowly-pinned key can’t enumerate other teams’ sites by probing for403s. - A resource you can see, missing the scope →
403with the codescope.insufficient. You’re already entitled to know it exists, so refusing the action by scope leaks nothing.
You only ever receive a 403 for something you were entitled to know exists. See
errors for the full type-to-status mapping.
Isolated scopes never ride in a wildcard
Section titled “Isolated scopes never ride in a wildcard”Five scopes are excluded from every wildcard, including the global *, and must be
granted exactly, one at a time: credentials:read, credentials:write, exec:raw,
keys:read, and keys:write. The broadest key you can mint still cannot reveal an
SFTP password, run an unfiltered exec, or list and mint other keys. The full rationale
is in scopes.
Every action is attributable
Section titled “Every action is attributable”Every mutation lands in the audit log with an actor_kind of
member, api_key, or system. When a key acted, the event carries both
actor_key_id (which key) and actor_user_id (the principal who owns it) — so “which
credential did this, and whose was it” is always one query away, via
GET /v1/account/audit, GET /v1/teams/{team_id}/audit, or
GET /v1/sites/{site_id}/audit with audit:read.
JWT safety: scopes can’t be forged
Section titled “JWT safety: scopes can’t be forged”The public API and the web app share an authentication seam, so the new key claims are designed not to weaken the existing JWT path:
- The new
kid(key id) andscopesclaims areomitempty— a human JWT session carries neither, and the scope gate treats an absent scope set as all scopes, so the web app is unchanged. - Scopes sourced from a JWT are rejected. Scopes are only honored when they come from a key the platform looked up in its database, never from a signed token. That means the shared signing secret can’t be used to mint a self-describing token that claims scopes — there’s no path to forge a scope.
Never put a key in a URL
Section titled “Never put a key in a URL”Server-sent event streams like GET /v1/jobs/{job_id}/stream
authenticate with the same Authorization: Bearer header as every other request —
there is no ?token= query parameter, deliberately. URLs land in logs, proxies, and
browser history; headers don’t. The browser’s EventSource can’t set an
Authorization header, so consume job streams from your backend or let the tooling do
it — the Go SDK’s Jobs.Follow and mf jobs watch both stream over the header.
Leak detection by literal prefix
Section titled “Leak detection by literal prefix”Every credential family carries a recognizable literal prefix — mfk_live_, mfk_test_,
mfs_live_. Those prefixes are registered with secret-scanning partners, so a key committed
to a public repository is matched on the prefix and auto-revoked, with a notification to
the owner. The prefix-as-type design from API keys is what makes this
work: there’s a stable, greppable signature to scan for.
Why opaque, DB-checked keys beat a stateless JWT
Section titled “Why opaque, DB-checked keys beat a stateless JWT”managed.dev keys are opaque random secrets checked against the database on every request, not self-contained signed tokens. The trade-off is one lookup per request, and it buys the property that matters most for a credential you hand to CI or a contractor:
- Instant revocation. Revoke a key and the very next request fails — there’s no signed token still valid until it expires, no revocation list to propagate. A stateless JWT can’t offer this; you’d be stuck either using very short lifetimes or building a denylist that reintroduces the same per-request lookup.
- Live re-derivation of tenancy. Because the principal is resolved on each request, a key’s effective access follows the owner’s current role and team membership — offboard the owner and their personal keys stop working, immediately.
For a long-lived automation credential, instant revocation is worth the lookup. That’s the deliberate choice behind opaque keys over a stateless shared-secret JWT.