# Authentication & keys

Every project-scoped endpoint goes through one front door. It accepts three
credentials, and the differences between them are worth understanding before
you issue anything.

## The three credential paths

| Credential | Prefix | Reaches | Resolves projects by |
| --- | --- | --- | --- |
| Dashboard session | — | Every project in every organization you belong to | id or slug |
| Project API key | `phk_…` | Exactly one project — its own | slug |
| Organization API key | `pha_…` | Any project in its organization | id or slug |

A **session** implies every scope: a human in the dashboard is already bounded
by what the UI offers.

A **project key** is pinned. The key's project is joined against the requested
slug in the same query that authenticates it, so a project key reaching for
another project is not a permission check that could be forgotten — it simply
finds nothing.

An **organization key** is the bootstrap credential. It can address any
project the organization owns and is the only credential that can *create* a
project. A project outside its organization returns `404`, not `403` — an org
key must not be usable to probe for project names in other organizations.

Send the key as a bearer token:

```http
Authorization: Bearer phk_live_…
```

**A presented-but-invalid key fails the request.** It does not fall through to
the session cookie. A credential you offered and that did not work is an
error, never a silent downgrade to something weaker.

## Scopes

Scopes are named `resource:action`. A key carries a fixed set, chosen when it
is created.

| Scope | Grants |
| --- | --- |
| `projects:read` | Read project configuration |
| `projects:write` | Create projects — organization keys only |
| `contacts:read` | Read contacts and subscription state |
| `contacts:write` | Create, update and sync contacts |
| `lists:read` | Read lists |
| `lists:write` | Create and update lists |
| `templates:read` | Read email templates |
| `templates:write` | Create and update email templates |
| `campaigns:read` | Read campaigns and their status |
| `campaigns:preview` | Render a campaign and resolve its recipients |
| `campaigns:send` | **Deliver a campaign** |
| `automations:read` | Read automations, steps and runs |
| `insights:read` | Beacons, revenue metrics and store reviews |
| `events:write` | Submit and preview transactional events |
| `events:send` | **Deliver a transactional event** |

Some endpoints require more than one scope. `GET /api/sends` returns contact
identities crossed with campaign activity, so it demands `campaigns:read`
**and** `contacts:read` — a key with only one of them gets `403`.

## The split that matters

Two scope pairs are load-bearing:

- `campaigns:preview` vs `campaigns:send`
- `events:write` vs `events:send`

An **agent key** — created with `--agent` — carries every scope except those
two send scopes. A model holding one can draft a campaign, resolve exactly who
would receive it, render the final HTML, read the send history, and preview a
transactional email. It cannot deliver a single message.

That is deliberate, and it is why it is comfortable to let a model drive a
sending system. If the guarantee lived in a prompt or a convention, it would
not be a guarantee. Living in the credential, it holds no matter what the
model decides to do.

The two pairs move together on purpose: a key that can loop a per-recipient
transactional send over your contact list *is* a key that can send a campaign.
Splitting one without the other would leave the door open.

In the dashboard, **Settings → API keys** offers the same preset. From the CLI:

```bash
# Everything except sending — the default for anything a model touches.
pnpm api-key:create acme-app "Claude Code" --agent

# A server that sends real mail needs the send scope explicitly.
pnpm api-key:create acme-app "Production" events:write,events:send
```

## Issuing keys

Creating and revoking keys is an **owner/admin** action, the same authority as
managing a sending credential — because a key carrying `campaigns:send` can
mail your entire audience, which is the same power by a different route. See
[Workspaces & roles](/docs/workspaces#roles).

A key belongs to the workspace, not to the person who created it, so it keeps
working after they leave. Rotate keys when someone with admin access does.

## Expiry, rotation and revocation

The raw key is shown once, at creation. Pharos stores only its SHA-256 hash —
there is no way to recover a key, only to issue a new one.

Give a key an expiry (`--expires-days <n>` from the CLI, or a value between 1
and 3650 days in the dashboard) and it self-revokes. An expired or revoked key
fails authentication like any invalid credential.

To rotate: issue the new key, deploy it, then revoke the old one. Nothing
about a key is mutable, so rotation is always create-then-revoke.

## Rate limits

Key-authenticated requests are limited to **240 requests per minute, per
key**, in fixed one-minute windows. Sessions are not limited.

Over budget, you get:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 23

{ "error": "Rate limit exceeded" }
```

Honour `Retry-After` and back off rather than retrying immediately. The limit
exists so that a runaway agent loop or a leaked key degrades into `429`s
instead of an unbounded contact export or a sender-reputation incident.

## Auditing

Every key-authenticated request writes one audit row: which key, which
project, method, path, the scope demanded, and the outcome — `ok`,
`denied_scope`, `rate_limited` or `project_not_found`.

Invalid keys are *not* recorded. There is no key to attribute them to, and the
presented secret must never be written to a log.

Operators can read the log with `pnpm key-audit`.

## Keeping keys safe

- Server-to-server only. A `phk_…` or `pha_…` in browser code is a public key.
- One key per integration, so revoking one does not take down the rest.
- Narrowest scopes that work. Reach for `--agent` before hand-listing.
- Prefer project keys over organization keys for anything long-lived; an org
  key's blast radius is every project you own.
