Authentication
/api/v1 has one credential: an API key in the Authorization header. There is no session, no OAuth token and no signed request. §1.1 of the contract defines the header, the five properties a key must have and the two statuses that refuse it.- Header
Authorization: Bearer …- Key format
- sz_live_ + 32 hex
- Missing or invalid
- 401
- Scope or restriction violated
- 403
- Query-parameter auth
- not supported
- Where keys are issued
- /keys, after sign-in
3.1The header
docs/backend-api-contract.md §1.1
Authorization: Bearer sz_live_<32 hex>
A key is Bearer-style: whoever presents it is treated as its owner, so it belongs in a header on a TLS connection and nowhere else. The shape is a fixed prefix plus 32 hex characters :
sz_live_ + 32 hex characters
^ ^
prefix crypto.randomBytes(16).toString("hex") — 128 bits, docs/plan.md D103.2The five conditions a key must meet
docs/backend-api-contract.md §1.1 · refusal statuses from §1.1 and §1.2
Every request is checked against all five. A failure of the first three is an authentication problem (401); a failure of the last two is an authorisation problem (403).
| Condition | Status if it fails | What it means, and the field behind it |
|---|---|---|
unrevoked | 401 | api_keys.revoked_at is null. A revoked key is answered the same way as one that never existed, so a refusal is not a probe result (docs/security.md §3 item 2). |
unexpired | 401 | api_keys.expires_at is null or in the future. Expiry is a field you set when creating the key. |
owned by the API's user | 401 | The key's owner and the API's owner are the same account. The engine walks key → key owner → API owner equality, and there is no path that accepts a caller-supplied connection or token. |
scope-compatible with the verb | 403 | read covers GET; write covers POST, PATCH, PUT and DELETE. A key's scopes is either ["read"] or ["read","write"]. |
within its API restriction | 403 | If allowed_api_ids is set, the requested API must be in that list; null means every API the same user owns. |
- The API itself must be enabled. A toggled-off API answers 403
api_disabled— the owner flips it back in the dashboard. - The verb must be allowed on the API. Each API carries an
allowed_operationssubset of the five verbs; anything outside it is 403operation_not_allowed. - If the owner of the key is disabled, the key stops working. Disabling a user takes effect on the next request — logout for the session and 403 for the API (contract §2, admin row for
setUserStatus).
3.3Scopes and per-key restrictions
docs/backend-api-contract.md §1.1 · §4.6 ApiKeyMeta
Two independent controls narrow what a key can do, and both are set at creation:
- Scopes decide which verbs are reachable. A read-only integration gets
read, and a POST/PATCH/PUT/DELETE call against it is a 403key_scope_insufficientrather than a silently ignored write. allowed_api_idsdecides which endpoints are reachable. Give a partner one API and list only that id; leave it null for a key that should reach everything the account owns. A call outside the list is 403key_restricted.
Scope is necessary but not sufficient: a write-scoped key still needs the API to be read_write, the verb in its allowed_operations, and — for PATCH, PUT and DELETE — a configured primary-key column. Those refusals are documented per operation on the Operations page.
3.4Key preview versus plaintext
docs/backend-api-contract.md §4.6, §4.8 · docs/security.md §2, §4
This is a documented security property, so it is stated here rather than buried in the dashboard: a plaintext API key is shown exactly once, at creation.
- The database stores
key_hash— the SHA-256 of the plaintext — and nothing else. Authentication hashes what you send and looks that hash up (§4.7authenticateKey), so the platform cannot read your key back . - The only displayable form is
keyPreview, documented as"sz_live_ab12…last4"— the first 10 plus the last 4 characters . A key list never carries the hash or the plaintext: both are projected out before serialization. - The plaintext exists only in the return value of
createKeyandregenerateKey(CreatedApiKey = ApiKeyMeta & { plaintext }, §4.6). Losing it means issuing a new key — there is no recovery, and that is the design. - Regenerating is revoke-plus-create, never an in-place mutation of a hash, so the history of the old key survives and a fresh plaintext comes back once.
- Even the dashboard's own playground sends a key id, never a key string: it calls the engine in process with
{ kind: "keyId" }, so no plaintext enters the browser or a network log.
3.5An authenticated call, and a refusal
docs/backend-api-contract.md §1.1, §1.2
The request
The builders add the Authorization header themselves from the apiKey field, so the header is not something an example can forget.
curl 'https://sheet.zaber.tech/api/v1/<api-id>?limit=1' \ -H "Authorization: Bearer sz_live_…"
No header at all
{
"error": {
"code": "missing_key",
"message": "<what failed>",
"category": "auth"
}
}`code` names the reason — `missing_key` when the header is absent, `invalid_key` when it does not resolve.
Where a key comes from
Keys are issued in the dashboard, where you also set the name, the scopes, the API restriction and the expiry. The creation form returns the plaintext once; the list after that shows previews only.