OBLETHdocs

Identity tokens

Let callers use short-lived JWTs from your identity provider instead of API keys, with a per-user key created on first use.

5 min readUpdated for v2.4.0Edit on GitHub

You needAn OIDC provider, or an app that signs tokens and publishes a JWKS
Turn it onOBLETH_JWT_ISSUERS, then restart the gateway
Each user getsAn identity key under the tenant you name

A caller sends the token wherever an API key goes: Authorization: Bearer … or x-api-key. The gateway verifies it offline against the issuer's published public keys (JWKS), maps the issuer and subject to an identity key, and from then on treats the request like one made with a key: tenant status, schedules, budgets, model allowlists and guardrails all apply. This works on every data-plane route, including /v1/messages and /mcp/<server>.

Turn it on

  1. 1

    Describe the issuer

    Set OBLETH_JWT_ISSUERS to a JSON array with one entry per issuer you trust:

    [
      {
        "issuer": "https://idp.example.com",
        "audience": "obleth",
        "tenant": "cli-users",
        "algorithms": ["ES256"],
        "device_claim": "device_id",
        "identity_claims": ["uid"]
      }
    ]
    

    On Kubernetes, set obleth.jwtIssuers in your values. With obleth.existingSecret, put the JSON under the OBLETH_JWT_ISSUERS key of that Secret. On Compose, set it in deploy/docker/.env.

  2. 2

    Restart the gateway

    The variable is read once, at startup. On Compose, run docker compose -f deploy/docker/docker-compose.yml up -d obleth. The Helm chart rolls the pods when its own Secret changes; after editing an existing Secret, roll the deployment yourself.

  3. 3

    Check the logs

    INFO obleth: jwks loaded issuer=https://idp.example.com
    INFO obleth: jwt bearer auth enabled issuers=1
    

    Neither line means the variable is empty, and every credential is treated as an API key. jwks not loaded at boot means the key document could not be fetched; that issuer's tokens fail until a refresh succeeds. An invalid configuration stops the gateway from starting.

  4. 4

    Send a token

    curl -s https://gateway.example.com/v1/chat/completions \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"model": "my-model", "messages": [{"role": "user", "content": "ping"}]}'
    

    A 200 means the token verified and the user's identity key exists. It shows on the API Keys page as identity · <subject>. A 401 carries no detail; see Troubleshooting.

Issuer settings

FieldDefaultMeaning
issuerrequiredMust equal the token's iss exactly
audiencerequiredMust appear in the token's aud
tenantrequiredThe tenant identity keys are created under
jwks_urldiscoveredThe key document. Without it, jwks_uri is read from <issuer>/.well-known/openid-configuration at startup
algorithms["RS256", "ES256"]Allowed signing algorithms. Asymmetric only; HS* stops the gateway at startup
subject_claimsubThe claim that names the user
device_claimnoneA claim recorded as device_id on usage rows (letters, digits, _, -; up to 64 characters)
identity_claims[]Extra claims stored on the identity key when it is created
jit_provisiontrueCreate the identity key on first use. With false, unknown users get 401

If the named tenant does not exist, it is created with default policy and an alert is raised. Where your account system already manages that tenant, treat the alert as a misconfiguration.

What a token must carry

  • A compact JWS (three base64url parts starting eyJ) of at most 8,192 bytes. Anything else is looked up as an API key.
  • A header alg from the issuer's algorithms, and a kid. Keys are looked up by kid alone.
  • iss and aud matching the issuer entry, a non-empty string subject, and exp. nbf and iat are checked when present, with 60 seconds of clock skew.

If your provider is a full OIDC server (Keycloak, Okta, Entra, Auth0, Dex), create a client whose audience is the configured value and you are done. If you sign tokens yourself, generate an ES256 or RS256 keypair, keep the private key in your own secret store, publish the public key with a kid at an unauthenticated JWKS URL, and sign short-lived tokens (minutes to hours) with iss, aud, sub and exp. The gateway never receives the private key.

Whoever holds the signing key can mint a token for any subject, and with jit_provision on the gateway creates a key for it. Protect it like the admin token.

Identity keys

Identity keys have no secret, so there is nothing to copy or rotate. Disable one to block that user at once, ahead of the token's expiry. Deleting one only resets it: the user's next token recreates it with fresh budgets. Anything scoped to a key is scoped to the user, such as who can see a video job.

The Management API returns kind, identity_issuer, identity_subject and identity_claims on each key; join external account systems on identity_subject. Request logs show the device_id of identity-key traffic, and GET /api/v1/usage/logs filters on device_id.

Rotating the signing key

The gateway refreshes each issuer's keys every 15 minutes, and a token with an unknown kid triggers at most one extra fetch a minute. If a refresh fails, the previous keys keep working.

  1. Generate a new keypair with a new kid.
  2. Publish both public keys and wait for the gateway to fetch them.
  3. Sign new tokens with the new key.
  4. Remove the old public key once every token it signed has expired.

Troubleshooting

Every verification failure returns 401 invalid token. A verified user whose key cannot be created gets 503 identity provisioning unavailable. Read the reason from the metrics port:

curl -s http://127.0.0.1:9091/metrics | grep obleth_jwt
obleth_jwt_verify_total{result}Cause
unknown_issueriss matches no configured issuer. Compare exactly, trailing slash included
bad_algThe header's alg is not allowed for this issuer
unknown_kidNo kid, or no loaded key has it
bad_signatureThe key with that kid did not verify the token
expired, not_yet_validOutside exp or nbf; check clock drift
bad_audienceaud is missing or lacks the configured audience
bad_subjectThe subject claim is missing, empty or not a string
malformedNot a parseable JWT, or over 8,192 bytes
not_provisionedVerified, but unknown and jit_provision is false
provision_failedVerified, but the key could not be created (503, and an alert)

not_provisioned and provision_failed are counted as well as ok. If no counter moves at all, the credential never reached the verifier: the feature is off, or the value does not look like a JWT. obleth_jwks_refresh_total{issuer_index, result} counts key-document fetches; a steady error rate means tokens signed by any newly rotated key will fail.

Why a token cannot be forged

Anyone can generate a keypair, but the gateway trusts only the keys in the JWKS documents of the issuers you listed. A token signed with any other key fails with unknown_kid or bad_signature. Forging one needs the issuer's private key, write access to its JWKS, or access to the gateway's environment, each of which already means control of the system. Symmetric HS* algorithms are refused at startup, so a public key can never be replayed as an HMAC secret, and none is never accepted. The JWKS URL is subject to the same outbound address policy as model backends.