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.
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.
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.