---
title: Authentication & key scoping
description: Bearer pf_ keys, mailbox-bound scoping, the data-plane vs control-plane split, and anti-enumeration 404s.
---

# Authentication & key scoping

Every `/api/v1/` request authenticates with a bearer API key. Keys are opaque secrets that
start with `pf_` and are stored only as a SHA-256 hash server-side.

```
Authorization: Bearer pf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

A missing or malformed header returns `401 {"error": "missing bearer token"}`; an unknown key
returns `401 {"error": "invalid api key"}`.

## Key scopes

Each key carries one scope. Only three scopes can authenticate a bearer request:

- **`bootstrap`** — provision-only. Accepted by `POST /api/v1/mailboxes` (to create a mailbox)
  and nothing else.
- **`api`** — the standard key for direct HTTP use.
- **`mcp`** — the standard key for the MCP server.

`api` and `mcp` are interchangeable across the data-plane endpoints (send, read, drafts). A
key whose scope is not permitted for an endpoint returns
`403 {"error": "scope '<scope>' not permitted for this endpoint"}`.

## Mailbox-bound keys

A key is either **account-wide** (`mailbox_id` is null) or **bound to a single mailbox**
(`mailbox_id` set). A bound key can only ever act on its one mailbox.

Each key also carries two capability flags:

- **`can_read`** — may read messages and drafts (`GET` routes).
- **`can_send`** — may send and create/edit/delete/send drafts (`POST`/`PATCH`/`DELETE`).

Both default to true; a key is restricted only when a flag is explicitly set false.

### 403 `key_scope`

When a key lacks the capability or targets a different mailbox, the response is `403` with
`code: "key_scope"`:

```json
{ "error": "this api key cannot send", "code": "key_scope" }
```

You will see `code: "key_scope"` in these cases:

| Situation | Example message |
|---|---|
| `can_send=false` on a send/draft-write endpoint | `this api key cannot send` |
| `can_read=false` on a read endpoint | `this api key cannot read messages` |
| Bound key, body `mailbox_id` is a different mailbox | `this api key is scoped to a different mailbox` |
| Bound key calls an account-plane endpoint (mailboxes/domains) | `this api key is scoped to a single mailbox` |

## Data plane vs. control plane

Postfleet splits its surface deliberately. **API keys operate the data plane. Sensitive
control-plane operations are dashboard-only** and are never exposed to a bearer key.

**Data plane (API keys):**

- Send email — `POST /api/v1/send`
- Read messages — `GET /api/v1/messages`, `GET /api/v1/messages/:id`
- Drafts — `POST`/`GET /api/v1/drafts`, `GET`/`PATCH`/`DELETE /api/v1/drafts/:id`,
  `POST /api/v1/drafts/:id/send`
- Provision mailboxes and custom domains — `POST /api/v1/mailboxes`, `/api/v1/domains`
  (**account-wide keys only** — a mailbox-bound key gets `403 key_scope` here)

**Control plane (dashboard only — no API):**

- Creating and revoking API keys
- Managing send allow/blocklists
- Approving or rejecting pending drafts
- Toggling a mailbox's `require_approval` setting
- Configuring the webhook URL/secret and redriving dead-lettered webhook deliveries

An agent holding a data-plane key therefore cannot escalate its own permissions, edit
policy, approve its own drafts, or replay webhooks — those levers stay with a human.

## Anti-enumeration: cross-scope ids return 404

Resource-id endpoints never confirm that an id exists outside your scope. A message or draft
id that belongs to **another account**, or (for a mailbox-bound key) to **another mailbox in
your own account**, returns the same `404` as a truly missing id:

```json
{ "error": "message not found" }
```

```json
{ "error": "draft not found" }
```

This is byte-identical to the "never existed" response, so a probe cannot tell "not yours"
from "does not exist". (Body-supplied `mailbox_id` mismatches on `POST /api/v1/send` and
`POST /api/v1/drafts` are the exception — those are `403 key_scope`, since a body field is a
scope violation rather than an id probe.)
