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: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) hasregistration_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 includedevices:read,devices:act,devices:lease,runs:startororders:place.
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 thehint 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.503when Clerk does not answer in time or answers with an error. It is never turned into a401, so a client keeps its token and retries.
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
403permission_denied: start it in the console; - call
/mcp, where it sees the device and run tools.
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: apbk_ prefix, a key id,
and a secret.
- 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 withPOST /v1/runs/{runId}/cancelor thecancel_runtool. A lease the key took stays until it is released or expires.
/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:listToolsshows 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.
When authentication fails
Authentication fails closed, always.- An unauthenticated or invalid request gets
401, with aWWW-Authenticate: Bearerchallenge 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.
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.