<!--
VERIFY-AGAINST-IMPLEMENTATION (ROADMAP42 D-1, lane DOCSINFRA, 2026-08-10)

This page is written to the frozen API-key contract in
.claude/context/agent-ownership.md. Lane KEYS implements that contract
concurrently, so the claims below were NOT executable against a running server
at the time of writing. The backend image on the e2e stack predates the API-key
code; `Authorization: Bearer` there still carries only a session JWT, and no
`X-RateLimit-*` headers are emitted on any response.

Unverified at authoring time — the integrator must reconcile each against the
merged implementation before this page ships:
  1. POST /api/v1/api-keys request and response bodies, and the 201 status.
  2. GET /api/v1/api-keys list shape (that `key` and `key_hash` are absent).
  3. DELETE /api/v1/api-keys/{id} returning 204.
  4. `ao_` prefix and the 43-character random tail.
  5. That `require_auth` accepts `Authorization: Bearer ao_...` at all.
  6. Error bodies: invalid_api_key (401), insufficient_scope (403),
     cookie_auth_required (403), including the `required` field on 403.
  7. Scope enforcement by HTTP method (read = GET/HEAD only, etc).
  8. Revocation taking effect on the very next request.
  9. `expires_in_days` semantics and the returned `expires_at`.
 10. CSRF exemption for key-authed requests.
 11. Rate limit 120/60s per key and the three X-RateLimit-* headers.
 12. Audit tagging of key-authed mutations with the api_key id. (The page now
     claims only that audited rows carry the tag, NOT that every mutation is
     audited — auditing is an explicit per-site call, present in 13 of 95
     router modules, so blanket coverage is not a defensible claim.)
 13. The Settings -> API Keys UI path.

VERIFIED LIVE against http://localhost:8000 on 2026-08-10 (safe to keep):
  - An unauthenticated request to a protected route returns
    401 {"detail":"missing bearer token"}.
  - Cross-tenant object access returns 404, never 403.
-->

# Authentication

Accounting Orbit has two authentication mechanisms. Pick by caller type.

| Caller | Mechanism | CSRF token needed |
|---|---|---|
| Browser (the Orbit web app) | HttpOnly session cookie | Yes, on mutations |
| Server, script, or AI agent | API key, `Authorization: Bearer ao_...` | No |

Everything on this page except the cookie column is about API keys.

## Getting a key

Keys are minted in the product UI only: **Settings → API Keys → Create key**.
There is no bootstrap endpoint — you cannot mint the first key over the API,
by design (see [Cookie-only surface](#cookie-only-surface)).

Once you are signed in as a browser session you may also call the key
management endpoints directly:

```bash
curl -X POST https://api.accountingorbit.com/api/v1/api-keys \
  -H 'Content-Type: application/json' \
  -H 'X-CSRF-Token: <csrf>' \
  --cookie 'access_token=<session cookie>' \
  -d '{"name": "billing-bot", "scope": "write", "expires_in_days": 90}'
```

`201 Created`:

```json
{
  "id": "7f1c2b30-4b6c-4f43-9a1a-2b0c9c1f8e22",
  "name": "billing-bot",
  "scope": "write",
  "prefix": "ao_9fK2mQ7x",
  "key": "ao_9fK2mQ7xLd3Rz8VtY1pB6nHcW0sJ4eA5uT7iO2gX9kM",
  "created_at": "2026-08-10T14:02:11.418200",
  "expires_at": "2026-11-08T14:02:11.418200"
}
```

Request fields: `name` (required, free text, used only for your own
bookkeeping), `scope` (required, one of `read` / `write` / `admin`),
`expires_in_days` (optional; omit for a key that never expires).

**`key` is returned exactly once, in this response.** It is stored server-side
only as an argon2id hash and cannot be re-read, re-sent, or recovered. Capture
it here or create a new key.

`prefix` is the first 12 characters of the key, stored in plaintext so the
server can find the right row in one lookup before verifying the hash. It is
safe to log and safe to show in a UI — use it to identify a key later.

## Key format

```
ao_<43 url-safe random characters>
```

Total length 46. The `ao_` marker is stable; treat anything else as not an
Orbit key.

## Using a key

```bash
curl https://api.accountingorbit.com/api/v1/invoices \
  -H 'Authorization: Bearer ao_9fK2mQ7xLd3Rz8VtY1pB6nHcW0sJ4eA5uT7iO2gX9kM'
```

The header is accepted anywhere a session cookie is, on the routes listed
under [Reachable surface](#reachable-surface). No cookie, no `X-CSRF-Token`,
no login round-trip.

A key is bound to exactly one tenant — the tenant it was created in. There is
no tenant selector, header, or query parameter that changes this. See
[/docs/conventions.md](/docs/conventions.md#tenant-scoping).

## Scopes

A key carries exactly one scope, fixed at creation. Scopes are cumulative.

| Scope | May issue | Typical use |
|---|---|---|
| `read` | `GET`, `HEAD` only | Reporting, dashboards, an assistant that only reads books |
| `write` | everything `read` may, plus `POST`, `PUT`, `PATCH`, `DELETE` on the reachable surface | Normal integrations: create customers, invoices, upload receipts |
| `admin` | everything `write` may, plus routes flagged as admin | Provisioning and configuration flows |

Scope is checked by HTTP method, not by endpoint semantics. A `read` key
calling `POST /api/v1/invoices` fails on the method before the body is looked
at.

Violation — `403`:

```json
{"detail": "insufficient_scope", "required": "write"}
```

`required` names the minimum scope the route needs. Widening a key's scope is
not possible; create a new key at the higher scope and revoke the old one.

## Reachable surface

Key auth reaches only an explicit allowlist of bookkeeping routes. The
allowlist is deny-by-default and matched on the route's path template, not on a
string prefix.

Reachable in v1: customers, invoices and invoice payments, vendors, bills,
receipts (upload, list, confirm), bank statement import and bank transactions,
and the report endpoints (trial balance, income statement, balance sheet, cash
flow).

Anything else, presented with a valid in-scope key, returns `403`:

```json
{"detail": "cookie_auth_required"}
```

This is not an error in your credentials. It means the route is browser-only
and no key of any scope reaches it.

### Cookie-only surface

Not reachable by any key, ever:

- Auth and session (login, logout, refresh, password reset, email verification)
- MFA enrollment and challenge
- Billing and Stripe
- Platform-admin and owner-dashboard routes
- Tenant provisioning
- The API-key endpoints themselves (`POST /api/v1/api-keys`,
  `GET /api/v1/api-keys`, `DELETE /api/v1/api-keys/{key_id}`)

The last one is the important one: **a key cannot mint another key.** A leaked
key cannot be used to widen its own scope, extend its own expiry, or outlive
its own revocation.

## Rotation

Keys are additive; there is no in-place rotation and no grace window applied
for you. Rotate like this:

1. Create the replacement key (new `id`, new secret).
2. Deploy the new secret to your caller.
3. Confirm traffic is flowing on the new key (`last_used_at` advances on the
   new row).
4. Revoke the old key.

To list your keys:

```bash
curl https://api.accountingorbit.com/api/v1/api-keys \
  --cookie 'access_token=<session cookie>'
```

The list response contains `id`, `name`, `scope`, `prefix`, `created_at`,
`last_used_at`, `expires_at`, `revoked_at`. It never contains `key` or
`key_hash`.

## Revocation

```bash
curl -X DELETE https://api.accountingorbit.com/api/v1/api-keys/7f1c2b30-4b6c-4f43-9a1a-2b0c9c1f8e22 \
  -H 'X-CSRF-Token: <csrf>' \
  --cookie 'access_token=<session cookie>'
```

`204 No Content`. The key row is kept (so audit history still resolves) with
`revoked_at` set.

**Revocation is effective on the very next request.** There is no cached
verification result and no TTL that defers it. A request already in flight
completes; the one after it fails.

## Expiry

If `expires_in_days` was supplied, the key stops authenticating the moment
`expires_at` passes. Expired keys are not auto-deleted and not auto-renewed.
An expired key is indistinguishable from a revoked or unknown key to the
caller:

```json
{"detail": "invalid_api_key"}
```

Status `401`. The same body is returned for a malformed key, an unknown key, a
revoked key, and an expired key — deliberately, so the endpoint cannot be used
to probe which keys exist.

## Error reference

| Status | Body | Meaning |
|---|---|---|
| 401 | `{"detail": "invalid_api_key"}` | Key is unknown, malformed, revoked, or expired |
| 401 | `{"detail": "missing bearer token"}` | No `Authorization` header and no session cookie |
| 403 | `{"detail": "insufficient_scope", "required": "write"}` | Valid key, wrong scope for this method |
| 403 | `{"detail": "cookie_auth_required"}` | Valid key, but this route is browser-only |
| 404 | `{"detail": "invoice not found"}` | The object exists but belongs to another tenant, or does not exist. Orbit does not distinguish. |
| 429 | `{"detail": "rate limit exceeded"}` + `Retry-After` | See below |

## Rate limits

120 requests per rolling 60 seconds, per key. The budget is per key, not per
tenant and not per IP — two keys in the same tenant do not share a budget.

Every key-authed response carries:

- `X-RateLimit-Limit` — 120
- `X-RateLimit-Remaining` — requests left in the current window
- `X-RateLimit-Reset` — seconds until the window frees up

Over budget: `429` plus `Retry-After: <seconds>`. Wait that long; do not
retry immediately, and do not spread the same work across extra keys.

Full detail in [/docs/conventions.md](/docs/conventions.md#rate-limits).

## Audit

Where a mutation is audited, the audit row is tagged with the `api_key` id that
made it, so you can attribute a recorded event to a specific key rather than
just to a user.

Two things to be precise about, because they are different claims:

- **Integrity is guaranteed.** Audit rows are append-only — updates and deletes
  are blocked at the ORM layer — and each row is SHA-256 hash-chained to the
  previous row for its tenant. Altering or removing a recorded event is
  therefore detectable.
- **Coverage is not universal.** Auditing is an explicit call at each site, not
  blanket middleware. Do not assume that every write you make through the API
  produces an audit row. If you need a guaranteed record of a specific
  operation, keep your own log of it too.

## Next

- [/docs/conventions.md](/docs/conventions.md) — errors, pagination, idempotency, dates
- [/docs/api.md](/docs/api.md) — generated endpoint reference
- [/docs/index.md](/docs/index.md) — all doc pages
