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.
Code
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 isNOT_FOUND(§7). - Every tenant-scoped route MUST verify the caller against the account that
owns the scope in the path —
accounts/{account},projects/{project}orwebsites/{website}. Scoping by path alone is not authorization. /site-apiis 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:
- 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_FOUNDon 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). Routes without a scope — the caller's ownprofile, the list of theiraccounts, the catalogues — need only a valid token. - 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) andREADorWRITE, whereWRITEincludesREADand a grant on a section covers every section below it. A section may declareimplies— grants that come with it, so that holding blog posts onWRITEalso lets the editor upload the post's image. - 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) takesREADon the section, any other methodWRITE; a custom method that only reads is declared as a read. A section markedreadable_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): 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.