5. Authentication
Three credential types. Which one you hold decides what you can reach.
| Credential | Prefix | For | Reaches |
|---|---|---|---|
| Virtual key | pk_live_ | your code | /v1/* |
| Sandbox key | pk_test_ | CI and integration tests | /v1/* reads; refused on anything that spends |
| Session JWT | — | dashboard / human actions | /v1/* |
| Internal key | internal_ | operators | /internal/v1/* only |
All are Authorization: Bearer <token>. Vendor SDK headers are also accepted:
x-api-key works for Anthropic-shaped clients.
Anonymous reads
Three GET routes need no credential at all, so a public site can render a
price list and a catalog before anyone signs up: GET /v1/billing/plans,
GET /v1/catalog/models and GET /v1/catalog/models/{id}. Anonymously the
catalog reflects only the platform's global bindings (bound is what the
platform serves, never a tenant's shadow). Every other route, including
GET /v1/models (what your key can call), still requires a credential; a
request with none is 401 auth.unauthorized, and a credential that lacks the
scope is 403 auth.missing_scope.
Dashboard sessions
Bearer is canonical for code. The console at console.opennozzle.com never
holds a token in JavaScript: signup, login and refresh set two HttpOnly,
Secure cookies alongside the JSON body, and the browser sends them back on
its own.
| Cookie | Path | SameSite | Lifetime | Carries |
|---|---|---|---|---|
nozzle_access | / | Lax | 15 min | the access JWT; read by every /v1/* route |
nozzle_refresh | /v1/auth | Strict | 30 days | the refresh token; reaches only /v1/auth/* |
POST /v1/auth/refresh with an empty body {} rotates from the cookie;
POST /v1/auth/logout and any refused refresh clear both. A cookie session
is refused on /internal/v1/* and, on any mutating request, must carry an
Origin header naming a configured console origin (typed 403 auth.forbidden
otherwise) — that is the CSRF check, and it never applies to bearer callers.
The body fields are unchanged, so an SDK that reads access_token from the
response keeps working.
Choosing a tenant. A session names a user, and a user can belong to
organizations in several tenants. Send X-Nozzle-Tenant: <tenant_id> to act
in a specific one; omit it and the session acts in its earliest-joined
tenant. The value is re-checked against your memberships on every request:
a tenant you do not belong to is 403 auth.forbidden, and a value that is
not a UUID is 400 request.invalid. The header is ignored for pk_ keys,
which already belong to exactly one tenant, and it is in the CORS allow-list
so the console can send it cross-origin.
Sandbox keys
A pk_test_ key authenticates, carries scopes, and reads your own data exactly
like a live key — and is refused with 403 on any operation that spends real
money upstream: every inference modality, and launching a compute instance.
This is a refusal, not a mocked response. A fake completion would have to be invented for eight modalities and kept in lockstep with eight real wire shapes forever; the first time it drifted it would teach your test suite something false. "This key cannot spend" stays true with no maintenance.
Use it to verify auth wiring, scopes, and error handling in CI without spend.
Idempotency
Every mutating public route requires an Idempotency-Key header, except
inference — OpenAI SDKs do not send one, and mandating it would break drop-in
compatibility. Retrying with the same key returns the original result rather
than acting twice.
"Mutating" is wider than it first reads, so two cases that surprise people:
- The auth routes count.
POST /v1/auth/login,/logoutand/refreshall require the header, not just/signup. Each one writes a session row. DELETEcounts.DELETE /v1/byok/credentials/{id}without the header is400 idempotency.key_required, and the credential stays active.