Skip to content

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.

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.

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.

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 for 403s.
  • A resource you can see, missing the scope → 403 with the code scope.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.

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 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.

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) and scopes claims are omitempty — 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.

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.

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.