Section 5

5. Authentication

Three credential types. Which one you hold decides what you can reach.

CredentialPrefixForReaches
Virtual keypk_live_your code/v1/*
Sandbox keypk_test_CI and integration tests/v1/* reads; refused on anything that spends
Session JWTdashboard / human actions/v1/*
Internal keyinternal_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.

CookiePathSameSiteLifetimeCarries
nozzle_access/Lax15 minthe access JWT; read by every /v1/* route
nozzle_refresh/v1/authStrict30 daysthe 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, /logout and /refresh all require the header, not just /signup. Each one writes a session row.
  • DELETE counts. DELETE /v1/byok/credentials/{id} without the header is 400 idempotency.key_required, and the credential stays active.