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.
The hard invariants
Section titled “The hard invariants”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 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.
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 (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 for403s. - 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.
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 an SSE stream URL
Section titled “Never put a key in an SSE stream URL”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.
Leak detection by literal prefix
Section titled “Leak detection by literal prefix”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.