> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phoneuse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Two credential channels, eight scopes, one authorization context.

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

| | OAuth 2.1 | API key |
| - | - | - |
| For | The console, for a signed in person | MCP clients, the CLI, and headless callers |
| Token | A JWT from the authorization server | A `pbk_` prefixed key |
| Scopes | Device and run scopes for either org role; admins also hold `orders:place` | Exactly the scopes the key was issued with |
| Device access | The org's devices | The org's devices, optionally narrowed to a subset |

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:

```text theme={null}
GET /.well-known/oauth-protected-resource
GET /.well-known/oauth-protected-resource/mcp
```

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](#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](#api-keys).

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.

```bash theme={null}
export PHONEBASE_API_KEY="pbk_<keyId>_<secret>"
curl -sS https://mcp.phonebase.co/mcp \
  -H "Authorization: Bearer $PHONEBASE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

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](/getting-started/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.

| Scope | What it allows | Who can hold it |
| - | - | - |
| `devices:read` | Enumerating devices, observing a screen, reading a recorded outcome | Console session of either role, API key, authorized app |
| `devices:act` | Gestures and input on a leased device | Console session of either role, API key, authorized app |
| `devices:lease` | Taking and returning exclusive use of a device | Console session of either role, API key, authorized app |
| `runs:start` | Handing a device to an autonomous run, and watching or ending it | Console session of either role, API key |
| `orders:place` | Starting a prepaid Checkout order over REST (`POST /v1/checkout-sessions`) | Admin only: an admin's session, or an API key an admin minted or approved. A member's session and a member's keys cannot hold it |
| `apps:read` | Listing apps and versions, inventory, previews and jobs in the app library | Console session of either role, and keys minted from one |
| `apps:write` | Saving a build to the library and publishing it; used together with `apps:read` | Console session of either role, and keys minted from one |
| `apps:delete` | Permanently deleting a version from the library (not the same as uninstalling it from a device) | Admin only: an admin's session, or a key an admin minted |

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](/mcp/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.

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.