OBLETHdocs

Authentication

How the data plane, the Management API, and the dashboard check who is calling: credential headers, key format and hashing, identity tokens, and failure codes.

6 min readUpdated for v2.4.0Edit on GitHub

To create tenants and keys, see Tenants and keys. To set up identity tokens, see Identity tokens. To add dashboard sign-in through your IdP, see Dashboard SSO.

Data plane

Every request on port 8080 except GET /health carries a credential, an API key or an identity token, in one of two headers:

HeaderFormUsed
AuthorizationBearer <credential>First; the Bearer prefix is case-sensitive
x-api-key<credential>When there is no Authorization: Bearer header

Anthropic clients send x-api-key by default; set ANTHROPIC_API_KEY to a gateway key. The gateway strips both headers before forwarding to a model backend. The MCP gateway (/mcp/{server}) and /v1/verdicts accept the same credentials.

curl http://localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer $OBLETH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "example-model", "messages": [{"role": "user", "content": "Hello"}]}'

When OBLETH_JWT_ISSUERS is set, a credential that starts with eyJ, has exactly two dots and is at most 8 KiB is verified as an identity token. Anything else is looked up as an API key.

Failure responses

ConditionStatusmessage
No credential401missing bearer token
Unknown or deleted API key401invalid api key
Identity token failed verification, or unknown subject with jit_provision: false401invalid token
Redis error during the key or identity lookup503authentication backend unavailable
Verified token, but its identity key could not be created503identity provisioning unavailable
Key disabled403api key disabled
Tenant not active403tenant is not active
Tenant start time not reached403tenant is not active yet
Tenant expired403tenant access has expired
Outside the tenant's weekly schedule403tenant is outside its scheduled access window

Errors use {"error": {"message": "…", "type": "obleth_gateway_error"}}. /v1/messages and /v1/messages/count_tokens return the same statuses in the Anthropic error envelope, with missing api key when no credential is sent. A Redis error during the lookup returns 503, not 401, so a Redis outage does not look like a revoked key to clients.

API keys

PropertyValue
Formatsk_ followed by 48 hex characters (24 random bytes), 51 characters in all
Display prefix (key_prefix)First 18 characters
Stored hash (key_hash)SHA-256 hex of the full key; with a pepper, SHA-256 of key, a zero byte, then the pepper
Secret shownOnce, in the create response
Redis entryobleth:key:<hash>

The pepper is OBLETH_API_KEY_PEPPER (Helm obleth.apiKeyPepper). With it set, a copy of the database alone cannot confirm guessed keys. Adding, changing or removing it invalidates every secret key issued before, and a restored backup authenticates only on a gateway with the same pepper.

Each key has a kind:

kindCredentialCreated by
secretThe sk_ keyThe dashboard or POST /api/v1/tenants/{id}/keys
identityAn identity token; there is no secretThe gateway, on the first verified token for a subject

Both kinds carry the same tenant, weight, max_in_flight, budget, model list and usage. What each key action does to authentication:

ActionEndpointEffect
Disable or re-enablePUT /api/v1/keys/{id}/disabled403 api key disabled while disabled
DeleteDELETE /api/v1/keys/{id}401 invalid api key; an identity key is re-created on the next token unless jit_provision is false
Move to another tenantPUT /api/v1/keys/{id}/tenantSame secret; requests count against the new tenant

The dashboard server holds one reserved key of its own, in a reserved tenant that is hidden from key lists and cannot be edited through the API. Every endpoint is in the Management API reference.

Identity tokens (JWT)

With OBLETH_JWT_ISSUERS set, the data plane accepts short-lived JWT access tokens from the issuers it lists. A verified token maps to an identity key, an api_keys row with kind = 'identity' and key_hash = jwt: + SHA-256 of issuer, a zero byte, and subject. From then on the request is handled like one with a secret key.

CheckRule
Issueriss must equal a configured issuer
Audienceaud must contain the issuer's audience
Required claimsexp, aud, iss; the header needs a kid
Timeexp, nbf and iat checked with 60 s of clock skew
SignatureAgainst the issuer's JWKS, with an allowed algorithm
SubjectThe subject_claim must be a non-empty string

OBLETH_JWT_ISSUERS is a JSON array read once at start. A parse error, a duplicate issuer, a disallowed algorithm, or a failed discovery fetch stops the gateway from starting. Each entry takes:

FieldDefaultMeaning
issuerrequiredhttp(s) URL; exact match on iss
audiencerequiredExact match on aud
tenantrequiredTenant name for new identity keys; created if missing
jwks_urljwks_uri from <issuer>/.well-known/openid-configurationWhere signing keys are fetched
algorithms["RS256", "ES256"]Any of RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, EdDSA
subject_claimsubClaim that identifies the user
device_claimunsetClaim copied to the usage row's device_id when it is 1 to 64 characters of A-Z a-z 0-9 _ -
identity_claims[]Extra claims stored in api_keys.identity_claims
jit_provisiontruefalse rejects a subject with no identity key with 401

Signing keys are cached per issuer and refreshed every 15 minutes. A token with an unknown kid triggers a refetch, at most one per issuer per minute. The discovery and JWKS URLs pass the same outbound-URL policy as model upstreams. Outcomes are counted in obleth_jwt_verify_total{result} and obleth_jwks_refresh_total{issuer_index,result} (Prometheus metrics).

Management API

Every request on port 9180 carries the admin token:

curl http://localhost:9180/api/v1/tenants \
  -H "Authorization: Bearer $OBLETH_ADMIN_TOKEN"

The whole header must equal Bearer <OBLETH_ADMIN_TOKEN>, compared in constant time; x-api-key is not accepted. A missing or wrong token returns 401 with {"error": "unauthorized"}. The token has no default: the gateway does not start without it. It grants full access, with no per-user roles. GET /api/v1/health, GET /api/v1/version and GET /api/v1/openapi.json need no token. Keep port 9180 private (Security).

Dashboard

The dashboard has its own sign-in; browsers never see the admin token. Its server calls the Management API with OBLETH_ADMIN_TOKEN.

PartHow it works
Accounts and sessionsDashboard auth tables in Postgres (DATABASE_URL)
Session signingBETTER_AUTH_SECRET, or DASHBOARD_SESSION_SECRET when unset; at least 32 characters
Break-glass adminSeeded from DASHBOARD_ADMIN_EMAIL and DASHBOARD_PASSWORD (at least 8 characters) when no active admin exists
Email sign-upClosed; accounts come from SSO or the break-glass admin
SSOOIDC_PROVIDERS (Dashboard SSO)
Allowed originsBETTER_AUTH_URL plus TRUSTED_ORIGINS (comma-separated; * trusts all)
Rolesadmin, or user (a portal user tied to one tenant)

A new SSO user starts as a pending user until an admin activates them on the Users page. The seed never re-promotes an existing account with the same email. No admin can demote or deactivate themselves or the last active admin.

Key lookup path

The gateway hashes the presented key and looks it up in an in-process cache (entries live up to 300 seconds; invalidation messages drop them early), then in Redis. The data plane does not read Postgres to authenticate a secret key. The Management API writes Redis on every change, and the gateway re-pushes keys from Postgres at start, every OBLETH_REDIS_REWARM_SECS, and after a Redis reconnect. The first token for a new identity writes its key to Postgres once, then warms Redis and the cache.