Auth Modes: API-Key Mint vs Local Signing
Behest supports two ways to produce a short-lived, per-end-user JWT. Both yield the same token format — Kong doesn't know (or care) which path you used. Pick based on latency, scale, and operational preference.
TL;DR
| API-key mint (default) | Local signing | |
|---|---|---|
| How | POST /v1/auth/mint with behest_sk_live_... | Sign RS256 JWT on your server with a tenant-scoped private key |
| Per-user network hop | 1 (to Behest) | 0 |
| Secret you store | API key | Private key (PEM) |
| Good for | Most apps; simple ops | High QPS backends; edge functions; air-gapped |
| Revocation | Rotate API key → all unexpired tokens still valid until exp | Rotate kid → JWKS drops old key → tokens fail |
| Setup time | 30s (create key in dashboard) | 5 min (generate key, upload public half) |
Rule of thumb: start with API-key mint. Migrate to local signing only if the mint call shows up in your latency budget, or if you want to mint millions of tokens without talking to Behest.
Mode 1: API-key mint
Your backend holds a long-lived API key (behest_sk_live_...). When a user needs to talk to Behest, you call:
POST https://amber-fox-042.behest.app/v1/auth/mint
Authorization: Bearer behest_sk_live_xxxxxxxxxxxx
Content-Type: application/json
{ "user_id": "user_123", "ttl": 900, "tier": 2, "session_id": "sess_abc" }The mint endpoint lives on your project slug host ({slug}.behest.app), not on a shared api.behest.app domain. The iss claim in the returned JWT is the string "https://api.behest.app" for backward compatibility, but that is an identifier — not a network endpoint you call.
{
"jwt": "eyJhbGciOi...",
"project_id": "663e...",
"ttl": 900,
"session_id": "sess_abc"
}The returned JWT carries claims { tid, pid, uid: "user_123", tier: 2, sid: "sess_abc", exp } and is signed by Behest's global key. Kong verifies it by fetching /.well-known/jwks.json.
Via the v1.5 SDK (recommended over raw HTTP):
import { Behest } from "@behest/client-ts";
const behest = new Behest(); // reads BEHEST_KEY (behest_sk_live_*) + BEHEST_BASE_URL
const { token, ttl, sessionId, expiresAt } = await behest.auth.mint({
user_id: "user_123",
tier: 2,
ttl: 900,
session_id: "sess_abc", // optional — SDK generates one if omitted
});When to use
- You're building a typical web or mobile app.
- You do not yet have performance pressure on mint.
- You want the simplest possible ops story.
Rotation
- Create a new API key in dashboard → update env var → delete the old key.
- Already-minted JWTs keep working until their
exp. If you need instant invalidation, shortenttl(default 15 min).
Never do this
- ❌ Ship
behest_sk_live_*to a browser, mobile app, or untrusted device. - ❌ Mint a JWT with
user_idthe client picks. Derive it from your own auth.
Mode 2: Local signing
Your backend holds an RS256 private key scoped to your tenant. You sign JWTs yourself, with the same claims Behest would produce. You upload only the public half; Behest serves it via JWKS so Kong can verify.
Setup
- Dashboard → Project → Signing Keys → Generate.
Behest returns a PEM private key + a
kid. Copy the private key — it is shown once. - Base64-encode the PEM and prefix it with
behest_pk_(or just setBEHEST_KEYto the raw PEM — the SDK accepts either). The public key is automatically added to the tenant JWKS; Kong picks it up within a few minutes.
Sign a token (v1.5 SDK auto-detects mode by key prefix)
TypeScript:
import { Behest } from "@behest/client-ts";
// Env:
// BEHEST_KEY=behest_pk_<base64-encoded-PEM>
// BEHEST_KID=<kid from dashboard>
// BEHEST_TENANT_ID=<tid>
// BEHEST_PROJECT_ID=<pid>
// BEHEST_BASE_URL=https://<slug>.behest.app
const behest = new Behest();
const { token, ttl, sessionId } = await behest.auth.mint({
user_id: "user_123",
tier: 2,
ttl: 900,
session_id: "sess_abc", // optional
});No HTTP round-trip — the SDK signs locally via jose. The same code works in Node, Cloudflare Workers, Vercel Edge, and Deno.
Python:
from behest import Behest
# Env as above (BEHEST_KEY starting with behest_pk_).
behest = Behest()
result = await behest.auth.mint(user_id="user_123", tier=2, ttl=900)
token = result.tokenRaw (any language) — sign a standard RS256 JWT with this header/payload:
// header
{ "alg": "RS256", "typ": "JWT", "kid": "<your kid>" }
// payload
{ "iss": "https://api.behest.app", "aud": "behest", "tid": "<tenant>", "pid": "<project>",
"uid": "user_123", "tier": 2, "role": "user", "scp": [],
"iat": 1734..., "nbf": 1734..., "exp": 1734..., "jti": "<uuid>", "sid": "sess_abc" }When to use
- You mint > 100 tokens/sec and the extra hop hurts.
- You run at the edge (Cloudflare Workers, Deno Deploy, Lambda@Edge) and want zero cold-start network calls.
- You have a policy requirement for "no outbound secrets traffic".
Rotation
- Generate a new signing key (new
kid). - Deploy the new key to your backend and switch over.
- Revoke the old
kidin dashboard → JWKS drops it → all unexpired JWTs signed with the old kid fail immediately. - This gives you real revocation. API-key mint cannot.
Revocation matrix
| Scenario | API-key mint | Local signing |
|---|---|---|
| Leaked API key / private key | Rotate key; old JWTs live until exp | Rotate + revoke kid; old JWTs die instantly |
| User logout (single user) | JWT expires at ttl | JWT expires at ttl |
| Global kill (all users) | Rotate + set short TTL globally | Revoke kid |
| Tier downgrade | Next mint reflects new tier | Next sign reflects new tier |
For most apps, short ttl (5–15 min) is sufficient — Kong also checks a kill-switch flag on every request (kill_switch:{pid} in Redis) for per-project emergency stops.
Sessions
Behest's session memory is keyed by {pid, uid, sid}. There are two ways to pin sid:
- Mint-time
session_id(recommended, both modes): includesession_idin the mint body (API-key mint) or in the signed claims (local signing). Kong injectsX-Session-Idfrom thesidclaim — the browser can't override it. - Header-only
X-Session-Id(legacy): the client sets the header on each request.
⚠️ Known limitation (until PLAN §7.2 ships): Kong does not yet validate that the
X-Session-Idheader is scoped to the caller'suid. Any authenticated user in the same project who can guess or obtain another user's session id can read that user's ephemeral session context. Mitigations:
- Use UUIDs, never incrementing or user-derived ids (e.g.
crypto.randomUUID(), notcheckout_${userId}_${ts}). Unguessable by construction.- Prefer mint-time
session_idso the header cannot be overridden from the browser.- Never base access decisions on
X-Session-Id— it is a scoping hint, not an authenticated claim, until server-side validation lands.- Threads (
X-Thread-Id) are already scoped server-side byuidand are not affected.
Dual mode in the SDK
@behest/client-ts v1.5 auto-picks a mode from the BEHEST_KEY prefix:
// Mode 1 — API-key mint (BEHEST_KEY=behest_sk_live_...)
const behest = new Behest({
key: process.env.BEHEST_KEY,
baseUrl: "https://amber-fox-042.behest.app",
});
await behest.auth.mint({ user_id: "user_123", tier: 2 });
// Mode 2 — Local signing (BEHEST_KEY=behest_pk_<base64-PEM> + BEHEST_KID/TENANT_ID/PROJECT_ID)
// Same code as Mode 1 — the SDK detects the prefix. No HTTP round-trip on mint.
const behest = new Behest(); // reads everything from env
await behest.auth.mint({ user_id: "user_123", tier: 2 });There is no browser "bring-your-own-token" mode in the SDK — browsers never construct a Behest instance. Instead, your backend mints a JWT with this SDK, hands it to the browser, and the browser uses the OpenAI SDK directly (new OpenAI({ apiKey: token, baseURL, dangerouslyAllowBrowser: true })). See React + Vite quickstart for the full pattern.
See the TypeScript SDK and Python SDK docs for the full option surface.
See also
- Authentication deep dive
- Core concepts
- Error handling — what 401 means in each mode