Skip to main content
Two credential channels reach the control plane. A JWT from the authorization server is how the phonebase console calls it for a signed in person. An API key is how everything else calls it, and it is the channel to use from an MCP client today. Both go through one pipeline. Both channels resolve into the same authorization context, so nothing downstream ever branches on which credential you used.

The two channels

Routing between them is by shape, not by configuration. A bearer value starting with pbk_ goes to the API key channel and anything else is treated as a JWT. The two cannot be confused: a JWT contains dots and a key token never does.

OAuth 2.1

The control plane is a resource server only. It does not issue tokens, run an authorization endpoint, or register clients. Those belong to an external authorization server. Discovery follows RFC 9728. A client fetches protected resource metadata from the endpoint and learns which authorization server to talk to:
Both paths are served, because clients try both.

What is verified about the flow

Read from the live discovery documents on 2026-09-22:
  • The hosted endpoint’s metadata names one authorization server, https://clerk.phonebase.co, which is a Clerk instance, and lists the scopes below.
  • That server’s own metadata (/.well-known/oauth-authorization-server) has registration_endpoint: null, so dynamic client registration is not available. It advertises the authorization code grant with PKCE (S256), and the scopes it advertises do not include devices:read, devices:act, devices:lease, runs:start or orders:place.
So a generic MCP client cannot register itself and finish a browser sign in against this endpoint. A client would need a client id registered in advance with that Clerk instance, and access tokens whose audience is https://mcp.phonebase.co/mcp and which carry your org as a claim. A pre-registered app has completed this flow in a live test. If you cannot register an app in advance, use an API key. Tokens are verified offline against the authorization server’s published keys. Verification checks the audience as well as the signature, so a token minted for something else is refused even when it is validly signed. The resource value advertised in the metadata is the same value the verifier checks, which means a client can never be sent to fetch a token this server would then reject. The org comes out of a claim on the token, using the same claim name the database’s row level policies read. There is no such thing as an authenticated caller without an org.

Tokens issued to an OAuth app

The console’s tokens carry your org role as a claim. A token Clerk issues to an OAuth app through the authorization code flow (for example a third party MCP client) carries the org and never a role: Clerk puts no custom claims on those tokens. For that token shape, and only that one, this server asks the issuing Clerk instance which role you hold in the named org, and caches the answer for up to 90 seconds. Removing someone from an org, or changing their role, therefore reaches this path within about a minute and a half. Three outcomes are specific to it. They use reasons that already exist, and the hint in the body says which case you hit:
  • invalid_token (401) with a hint saying you are not a member of this organization: the account is not a member of the org the token names, for example because it was removed after signing in. Sign in again and pick an org you belong to.
  • missing_org_role (401) with a hint naming an OAuth app: this deployment cannot ask the Clerk instance that issued the token. That is our configuration, not your credential, so retrying and signing in again both fail the same way.
  • 503 when Clerk does not answer in time or answers with an error. It is never turned into a 401, so a client keeps its token and retries.
The consent screen of such an app lists Clerk’s own scopes (such as user:org:read), not this server’s scopes. The server grants the app the device and run scopes for your org role. An admin’s app also holds orders:place. The app is limited to the routes listed below. It may:
  • list devices, read one, take a screenshot or UI snapshot, see who is driving one, and take a pay as you go device;
  • acquire, renew and release a lease, act on a device (tap, swipe, type, keys, open or install an app) and wake it;
  • read the org’s leases, and read one receipt by the idempotency key it chose;
  • start, list, read, pause, resume and cancel runs, and read a run’s frames;
  • create webhooks, and list, read, update, rotate, test and delete the ones it created, and read their deliveries. Webhooks created in the console, with an API key or by another app are invisible to it: they answer 404. Two limits follow from sharing one org: the cap on webhooks per org counts every webhook, so an app can use up the org’s allowance, and an app refused at the cap learns that other webhooks exist. A webhook that was switched off because the person who authorized the app left can be turned back on only by that person;
  • read the price list and place an order for a prepaid plan. Placing an order only creates a Stripe Checkout page; nothing is bought until you pay on that page yourself. Only an org admin’s app can place one, as in the console. Pay as you go answers 403 permission_denied: start it in the console;
  • call /mcp, where it sees the device and run tools.
Everything else answers 403 with permission_denied and a message naming where to do it instead (“This app may use devices, runs, webhooks and orders only; manage API keys in the PhoneBase console.”). That covers API keys, team management, the copilot, reading, cancelling or returning orders, billing and the billing portal, usage, onboarding status, notifications, the org’s receipt feed, renaming a device and live video sessions. An app is never staff on the operator surface. Within the device surface your org role applies exactly as it does in the console. Authorize only apps you would let drive your devices. When you leave an org, the webhooks apps created on your behalf there are switched off (this needs the deployment’s identity webhook to receive organizationMembership.deleted). Clerk sends no event when you revoke an app’s access or an app is deleted, so revoking an app does not by itself stop its webhooks: delete them in the console. A deployment can name OAuth clients that are its own (PHONEBASE_FIRST_PARTY_OAUTH_CLIENTS, each tied to the issuer that registered it). Their tokens are not apps and get the console’s surface; the role is still looked up the same way. The console’s own session is not an app: its token carries azp, and it keeps everything below. Which kind a token is comes from the verified token itself (client_id present and no azp), never from anything the app sends. A console session gets the device and run scopes, whichever org role it holds. An admin’s session also holds orders:place. A session can list devices, read one, read the org’s leases, receipts, usage and run history, and it can also acquire a lease, tap, swipe, type and start a run. The ordering scope is admin-only; route level role checks also distinguish admins from members. For a period ending on 2026-09-11 an OAuth principal got devices:read alone and could drive nothing. That is no longer true. If you built around the narrower grant, nothing you were doing has broken, but a session can now do more than it could. Acting from a browser session is bounded by the session itself. A key is the credential that outlives one, which is why minting a key needs a freshly verified factor and driving a device does not. Managing keys is not gated on a scope, and it is no longer gated on org role either: either role creates, rotates and revokes keys. What gates it is the principal kind (only an OAuth session, never an API key) and, on an existing key, who minted it: a member manages the keys they created, an admin manages any of them.

API keys

A key is a single bearer string with a fixed shape: a pbk_ prefix, a key id, and a secret.
What matters about them:
  • Shown once. The full token appears exactly once, in the creation response. Only a hash of the secret is stored, so no database read can yield a usable token. Lose it and you issue a new one.
  • Scoped. A key carries the scopes it was issued with, and no more.
  • Optionally narrowed to devices. A key can be restricted to a subset of the org’s devices. The subset narrows visibility and addressing together, so a device outside it is not merely refused, it is not found.
  • Revocable immediately, on the bearer channel. Verification runs against the store on every request, and the transport is stateless, so a revoked or expired key is refused from its next request onwards. Revoking does not recall what the key already started. An agent run it started keeps its vrxBaseUrl, which is a credential of its own and is not checked against the key, until the run ends: cancel it with POST /v1/runs/{runId}/cancel or the cancel_run tool. A lease the key took stays until it is released or expires.
Keys are managed over /v1/api-keys, and that surface requires an OAuth session: either org role, but never an API key. That closes the obvious self escalation path, and a second rule closes the other one: the scopes a mint requests have to be a subset of the scopes the calling session already holds, so nobody grants themselves authority they do not have. Each row records who minted it, and that is what rotate, renew and revoke check: a member acts on their own keys, an admin on any key in the org.

Getting a key

Mint one yourself, from the /keys screen in the console. It is the same /v1/api-keys surface described above (create, rename, renew, narrow, rotate, revoke), driven by any signed in member of your org, admin or not. Pick the scopes you need, say whether the key should be narrowed to specific devices, and the pbk_ token is shown to you once. You mint keys yourself rather than requesting one, and a key you already hold keeps working unchanged. Rotation, renewal and revocation are on the same screen. A rotated key keeps working through a short grace window (24 hours by default) while you swap in the new token, and a revoked key is refused from the next request onwards. If a request fails with expired_key or revoked_key, the error carries a hint that points back to this page: rotation or a fresh key is the way back, because an expired key is never resurrected in place. See The console for the screen, and for why the key you just minted is refused if you send it from a browser. Either way the red line holds: issuance is never something an API key can do. Operator-side issuance runs as a privileged process against the credential store directly, and it does not pass through this HTTP surface, so there is no issuance endpoint an API key could reach, and no configuration in which a key mints keys.

Scopes

There are eight. The device and run scopes partition the MCP tool surface; the ordering scope applies to the REST checkout route; the three apps scopes apply to the app library. A key never carries more than the session that minted it. The copilot has no scope of its own: its routes are person-only, so an API key cannot reach them. Which tool needs which scope is published per tool in the tool reference. runs:start is not a way around devices:act. Starting a run hands back a credential that drives the device, so a key holding devices:lease and runs:start but not devices:act could start a run and then act as its own worker, exercising power its scope set never granted. The run tools therefore require runs:start and devices:act, and the same rule is applied on the REST run surface so the two cannot disagree.

Your scopes decide which tools exist

This is the part that surprises people. Scopes are enforced when tools are registered, not when they are called. A tool your credential cannot use is never registered on the server built for your request. So:
  • listTools shows your true surface, not a menu with locked items.
  • Calling a tool that exists but needs a scope your key lacks returns an error that names the scope, for example “This key needs devices:lease to call acquire_device”. The tool still does not run. A name that is not a tool is a protocol level method not found.
  • The rule lives in exactly one place, so nothing re checks it later and nothing can drift.
If a tool you expected is missing, look at your credential’s scopes before you look for a bug.

When authentication fails

Authentication fails closed, always.
  • An unauthenticated or invalid request gets 401, with a WWW-Authenticate: Bearer challenge pointing at the resource metadata so a client can discover where to authenticate, and a structured reason in the body.
  • An authenticated caller lacking a permission gets permission_denied.
  • If the authentication infrastructure itself is unavailable, the answer is 503. It is never a pass. An outage refuses requests rather than letting them through.
The set of failure reasons is closed, and two distinctions in it are deliberate. An unknown key id and a wrong secret produce the same reason. There is no oracle for enumerating which key ids exist, and a miss still performs the same work a hit does so the two do not differ in timing. A revoked or expired key is reported distinctly. Whoever holds that key owned it, and telling them why it stopped working costs nothing and saves an afternoon.

Local development

Local mode has one org and one operator, and folds into the same pipeline rather than bypassing it.
  • Set PHONEBASE_DEV_TOKEN, and the endpoint requires that exact bearer value.
  • Leave it unset, and requests resolve to the local org unauthenticated, which is tolerable only because local mode refuses to bind anywhere but loopback.
Either way the caller gets every scope, and the shape of the resulting context is identical to a hosted one.
The development branch exists only when no hosted channel is configured. That is structural, not a convention: a development token sitting next to real credentials must never become a master key that short circuits them.