Skip to content

Security model

Preview Scoped keys are only as good as the invariants underneath them. This page is the contract: the rules that hold no matter which scopes or preset you pick, and why managed.dev chose them. They come straight from the API design (forge#154 §3) and are enforced at the authentication seam, not left to individual handlers.

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 is shown once, at creation, and only a SHA-256 hash is stored. The platform reuses the same hash-at-rest primitive as refresh tokens — it never copies the older plaintext-secret pattern used internally for some service tokens. A database compromise can’t reveal a live key, and there’s no plaintext column to leak. Lose a key and you 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 (or the runtime capability is absent), 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 insufficient_scope. 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.

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 are read by the browser’s EventSource, which can’t set an Authorization header — which tempts people to pass the key as a ?token= query parameter. Don’t. URLs land in logs, proxies, and browser history. Instead, the API exchanges your bearer key for a short-lived stream ticket, and the stream URL carries the ticket, not the key. The ticket is single-purpose and expires fast, so a leaked stream URL exposes nothing durable.

Every credential family carries a recognizable literal prefix — mfk_live_, mfs_live_, mfo_at_. 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.