# Authentication

There is one credential: a JSON Web Token in an `Authorization` header. No API keys, no
tokens in query strings, no cookies — an API call carries its own credential, and nothing
about it depends on a browser session.

```sh title="quickstart.sh"
token=$(curl_json -X POST "$TENANT_API/tenant-api/v1/users:authenticate" \
  -d "$(jq -n --arg email "$EMAIL" --arg password "$PASSWORD" \
        '{email: $email, password: $password}')" | jq -r '.token')

auth=(-H "Authorization: Bearer $token")
```

The token is a user's, and it reaches the accounts that user is a member of. A website id
in an account you do not belong to answers `NOT_FOUND`, not `PERMISSION_DENIED`, because
refusing by name would confirm that the site exists; inside your own account, a section
your role does not cover answers `PERMISSION_DENIED`. The rules — membership, role,
section, and the account's owner — are below.

**Straight after a signup there is a second way in.** The registration response carries a
one-time `signup_token`; trading it at `users:exchange_handoff` returns a session token,
which is how the platform's own dashboard opens already signed in without asking for the
password that was just chosen. It works exactly once.

**Sign-up is the one surface that needs no token at all**, and that is why every call on
it is rate-limited and bot-checked at the edge — a CAPTCHA proof travels with it. Nothing
behind that surface can read another tenant's data.

Two operational notes for a long-lived integration. A token expires: treat a `401`
(`UNAUTHENTICATED`) as "re-authenticate and retry once", not as a failure to report. And
in a browser, the preflight matters — `PATCH`, `Authorization` and `If-Match` are none of
them "simple", so a client that sees no response at all is usually looking at a refused
preflight rather than a refused request.

## 19. Authentication

- `Authorization: Bearer <jwt>` on every authenticated surface. No API keys, no
  tokens in query strings, no session cookies for API calls.
- Missing or unparsable credentials → `UNAUTHENTICATED` (401). Valid
  credentials without the right → `PERMISSION_DENIED` (403), **except** where
  403 would reveal existence, which is `NOT_FOUND` ([§7](/concepts/methods)).
- Every tenant-scoped route MUST verify the caller against the account that
  owns the scope in the path — `accounts/{account}`, `projects/{project}` or
  `websites/{website}`. Scoping by path alone is not authorization.
- `/site-api` is unauthenticated by design and MUST be rate-limited and
  bot-checked at the edge; nothing behind it may read another tenant's data.

### Authorization on the tenant API

A token names a **user**, and a user reaches an account by being a **member**
of it. What a member may do there is decided in three steps, in this order, on
every request:

1. **Membership.** The route's scope — an account, a project or a website —
   is resolved to the account that owns it, and the caller must be a member
   of that account. A caller who is not gets `NOT_FOUND` on the scope's name:
   the same answer a scope that does not exist gets, so an id belonging to
   someone else's account confirms nothing ([§7](/concepts/methods)). Routes without a scope — the
   caller's own `profile`, the list of their `accounts`, the catalogues —
   need only a valid token.
2. **Role.** Each membership holds exactly one role: `roles/admin` (every
   section, to change), `roles/editor` (every section except managing who has
   access), `roles/sections` (only the grants written on the membership
   itself), or one of the account's own roles, `accounts/{account}/roles/{role}`
   — a named set of grants. A **grant** is `{permission, level}`: a section of
   the permission catalogue (`GET permissions`) and `READ` or `WRITE`, where
   `WRITE` includes `READ` and a grant on a section covers every section
   below it. A section may declare `implies` — grants that come with it, so
   that holding blog posts on `WRITE` also lets the editor upload the post's
   image.
3. **Section.** Every scoped route belongs to one section, or is declared
   open to any member (the scope's long-running operations) or to the owner
   alone. Reading (`GET`) takes `READ` on the section, any other method
   `WRITE`; a custom method that only reads is declared as a read. A section
   marked `readable_by_all_members` — everything under a website, the
   account's settings, its projects — is readable by every member whatever
   the role, so pickers of media, pages or categories work for everyone; a
   grant on it decides what the member may change. Access management, the account's AI and its billing are read only
   with a grant.

A member whose role does not reach the route gets **`PERMISSION_DENIED`** (403,
[§13](/concepts/errors)): they belong to the account and already know it exists, so refusing by
name gives nothing away. `NOT_FOUND` is kept for the caller who is not a
member at all. What a member actually holds — the role's grants with `implies`
followed — is `effective_grants` on their own membership,
`accounts/{account}/members/{their user id}`, which every member may read.

Rights are read afresh on every request and are not carried in the token: a
changed role or a removed member takes effect on the very next call, and no
token has to be revoked.

**The owner.** An account that has members has exactly one owner, named by the
account's `owner` field (`users/{user}`); the first member of an account
nobody belongs to becomes its owner. Accepting an invitation never makes
anybody the owner: the new membership holds the role the invitation names,
no more. The owner holds every section whatever the
membership's role says, and two things are the owner's alone: deleting the
account and handing it to another member (`accounts/{account}:transfer_ownership`).
The owner's membership can be neither removed nor changed —
`FAILED_PRECONDITION` — until the account has been handed over; the previous
owner then stays a member with `roles/admin`.

**Nobody gives more than they hold.** Assigning a role or grants — to a member,
an invitation or an account role — that reach further than the caller's own
rights is `PERMISSION_DENIED`, naming the first grant too many. The owner and
`roles/admin` are never refused.

### Customers of a website

A website's **customers** — the visitors who register and sign in on the
tenant's site, `websites/{website}/customers` — are not callers of this API.
Their session lives between their browser and the site: HttpOnly cookies the
site sets on its own domain when they sign in (`Secure`, `SameSite=Lax`),
renews while they browse and clears when they sign out. The API itself stays
stateless and bearer-only. The tenant API manages customers and their groups
under the tenant's token like any other resource, and never accepts a
customer's session in place of that token: a customer's credential presented
to it is `UNAUTHENTICATED`.

A session belongs to one website. It opens nothing on another website — not
even one of the same account — and the same address on two websites is two
customers, each with a password of its own. Anything a site renders for a
signed-in customer is answered `Cache-Control: private, no-store`, so no
shared cache keeps it; a visitor without a session is served exactly what
every other anonymous visitor is.
