temperkb.io.temper-ui web application (SvelteKit) deploys as its own Vercel project with its own confidential OIDC client; it is fully config-driven (no per-org fork) and is documented in the UI section below.Doing a full ground-up enterprise install? This guide is one phase. For the single
end-to-end sequence (deploy → SAML → org → agents) see enterprise-install.md.
Vercel
┌──────────────────────────────────┐
CLI / MCP client │ │
──────────────────▶│ /.well-known/* ─┐ │
temper resource │ /oauth/* ├─▶ api/mcp │
temper login │ /mcp │ (MCP srv) │
│ /mcp/* ─┘ │
│ │
│ /(.*) ────▶ api/axum │
│ (REST API) │
└──────────────────────────────────┘
│ │
▼ ▼
Neon PG 17 Auth0 tenant
(pgvector) (JWT issuer)vercel.json at the repo root:handle: filesystem runs first (static files if any)./mcp, /mcp/*, /oauth/*, /.well-known/* route to api/mcp (the MCP server)./(.*) (catch-all) routes to api/axum (the REST API).framework is null; there is no framework-level routing. SQLX_OFFLINE=true is set in the build environment so the Rust macros compile against the committed .sqlx/ cache rather than a live database.neondb database and run:vector provides the pgvector embedding type used by the search pipeline. pg_uuidv7 provides in-database UUIDv7 generation. plpgsql is enabled by default and does not require an explicit CREATE EXTENSION.DATABASE_URL) — the host contains -pooler. Used at runtime.DATABASE_URL_UNPOOLED) — no -pooler suffix. Used for migrations only.postgresql://<user>:<password>@<host>/neondb?sslmode=require&channel_binding=requireDATABASE_URL=<DATABASE_URL_UNPOOLED> sqlx migrate runmigrations/. sqlx is the single migration authority — never apply schema changes by other means.DATABASE_URL and DATABASE_URL_UNPOOLED per preview branch. Pull-request preview deployments therefore get isolated databases with no manual wiring. The migration step still runs separately — Vercel does not run it automatically.Using Okta instead? This section is Auth0-specific. For standing up the same instance against
an Okta tenant in an enterprise context, see Self-Hosting with Okta —
it covers the custom authorization server, API Access Management requirement, and the
Okta-specific environment and CLI configuration. The rest of this guide (Neon, Vercel, verify)
applies unchanged.
AUTH_AUDIENCE in your Vercel environment. A conventional value is https://<instance>/api. See Auth identity: the variables that must agree for the full contract, including the optional MCP_AUDIENCE restatement.temper CLI:authorization_code, refresh_tokenhttps://<instance>/api/auth/cli-callbackclient_id is what users supply when running temper init with --auth-client-id.https://claude.ai/api/mcp/auth_callback, https://claude.com/api/mcp/auth_callback, http://localhost).client_id becomes MCP_CLIENT_ID in your Vercel environment.auth0 CLI:auth0 apis list # → shows identifier (your AUTH_AUDIENCE)
auth0 apps list # → shows client_id for each application@auth0/auth0-mcp-server) provides the same information in an agentic session.| Auth0 value | Environment variable | Notes |
|---|---|---|
| Tenant domain | AUTH_ISSUER | https://<tenant>.auth0.com/ — trailing slash required |
| Tenant JWKS endpoint | JWKS_URL | https://<tenant>.auth0.com/.well-known/jwks.json |
| API identifier | AUTH_AUDIENCE | The one audience — validated by both the REST API and the MCP server |
| Auth provider | AUTH_PROVIDER_NAME | Always auth0 |
| API identifier (MCP) | MCP_AUDIENCE | Optional. If set, it must equal AUTH_AUDIENCE — it restates the one audience, it does not add a second one |
| MCP app client_id | MCP_CLIENT_ID | The MCP native application's client_id |
| Instance base URL | MCP_BASE_URL | https://<instance> — no trailing slash |
framework override to Other (the vercel.json sets "framework": null). Configure the following environment variables in the Vercel project dashboard before the first deployment.| Variable | Surface | Required | Notes |
|---|---|---|---|
DATABASE_URL | api, mcp | Yes | Pooled Neon connection string (runtime) |
DATABASE_URL_UNPOOLED | deploy step | Yes | Direct Neon connection string (migrations only) |
AUTH_ISSUER | api, mcp | Yes | https://<tenant>.auth0.com/ — trailing slash required |
JWKS_URL | api, mcp | Yes | https://<tenant>.auth0.com/.well-known/jwks.json |
AUTH_AUDIENCE | api, mcp | Yes | The one audience both surfaces validate (e.g. https://<instance>/api). Boot fails if unset or empty |
AUTH_PROVIDER_NAME | api, mcp | Yes | Set to auth0 |
MCP_AUDIENCE | api, mcp | No | An optional restatement of AUTH_AUDIENCE. If set it must equal it; unset is the normal configuration |
MCP_CLIENT_ID | mcp | Yes | MCP native application client_id |
MCP_BASE_URL | mcp | Yes | https://<instance> — used in OAuth discovery responses |
API_BASE_URL | ui | No | Only for the optional web UI (a separate Vercel project); not required for API + MCP + CLI |
BLOB_READ_WRITE_TOKEN | api | Yes | Vercel Blob token — used by the upload/extract/embed pipeline |
ENABLE_SWAGGER | api | No | Set true to expose /swagger-ui in non-production deployments |
PORT | api | No | Platform-injected by Vercel; defaults to 3000. Only relevant for local or non-Vercel runs |
SQLX_OFFLINE | build | Yes | Must be true — compile-time SQL checks run against the committed .sqlx/ cache |
CORS_ORIGINS | api | Situational | See note below |
CORS_ORIGINS caveat: This variable is required for any client that calls the API cross-origin from a browser. When CORS_ORIGINS is unset, the API returns no CORS headers and cross-origin requests fail. Note the bundled temper-ui does not need it — it reverse-proxies API/MCP traffic same-origin through its own server (see Deploy the UI), so the browser never makes a cross-origin call. Set CORS_ORIGINS only if you run a separate browser-based client against the API directly. A permissive development value is *; production should list only the specific origins that need access.AS_ISSUER is set.AS_ISSUER unset. Auth0 or Okta mints tokens; Temper is a pure resource server. This is the shape the rest of this guide assumes, and the shape temperkb.io runs.AS_ISSUER set. Temper's own authorization server mints tokens: the mode that backs SAML and temper-issued machine credentials.| Variable | External IdP (AS_ISSUER unset) | Temper AS (AS_ISSUER set) |
|---|---|---|
AUTH_ISSUER | The IdP's issuer URL — https://<tenant>.auth0.com/ | Your instance's origin — must equal AS_ISSUER |
JWKS_URL | The IdP's JWKS endpoint — https://<tenant>.auth0.com/.well-known/jwks.json | Must be $AS_ISSUER/oauth/jwks — the AS publishes its own keys |
AUTH_AUDIENCE | The IdP's API identifier. The one audience, validated on both surfaces | Must equal AS_AUDIENCE |
MCP_AUDIENCE | Optional. If set, must equal AUTH_AUDIENCE; unset is normal | Optional. Same rule |
AS_ISSUER | Leave unset — setting it flips the instance into AS mode | Required. Your instance's origin (no trailing slash needed) — its presence is the mode signal |
AS_AUDIENCE | Leave unset — never read in this mode | Required. Must equal AUTH_AUDIENCE |
/ and the AS's own metadata strips them, so https://temper.acme.com and https://temper.acme.com/ are the same issuer as far as the gate is concerned.On an AS instance the three audiences are one value spelled three ways — not three independent knobs. The Temper AS mints every token, human and machine, with the single server-side AS_AUDIENCE, ignoring any request-suppliedaudience(packages/temper-cloud/src/oauth/mint.ts). SoAS_AUDIENCEis the audience minted,AUTH_AUDIENCEthe audience validated, andMCP_AUDIENCE— if you set it at all — merely restates it. They are the same string or the instance verifies nothing. Under an external IdP there is no AS, soAS_*is unset entirely: that is why the agreement rules are mode-dependent, and why no operator should be expected to hold them in their head.
# AS mode: issuer, JWKS, and audience all restate one instance. Derive, don't retype.
INSTANCE="https://temper.acme.com"
AS_ISSUER="$INSTANCE" # the mode signal
AUTH_ISSUER="$INSTANCE" # == AS_ISSUER
JWKS_URL="$INSTANCE/oauth/jwks" # == $AS_ISSUER/oauth/jwks
AS_AUDIENCE="$INSTANCE/api" # what the AS mints
AUTH_AUDIENCE="$INSTANCE/api" # == AS_AUDIENCE — what both surfaces validateAn incoherent auth config fails the boot. Both surfaces parse this identity once, at startup, through the same code ( crates/temper-services/src/auth_config.rs), and an instance that violates any rule above refuses to start — naming the offending variable and the relation it must satisfy (it never prints a value). That is deliberate. The old behavior was awarnline and a served request; a warning in a serverless log is not a control.
# Confirm the mode the instance booted in — "temper-AS" or "external-IdP"
vercel logs <deployment-url> | grep 'auth configured'JWKS_URL checks no signature against the keys that actually signed the token. Temper now names rules that were already true, and fails fast when they are not — which is why a hard boot failure cannot break a working deployment. It can only refuse to start one that was already broken and had not noticed.| Misconfiguration | Old behavior | Now |
|---|---|---|
AUTH_AUDIENCE unset or empty | The REST API set validate_aud = false and accepted any token from the issuer, regardless of which API it was minted for — audience validation silently off | Boot refuses |
MCP_AUDIENCE empty | The MCP server enforced aud == "" and rejected every token | An empty value is treated as absent, uniformly — the instance's one audience still applies |
MCP_AUDIENCE set to something else | The two surfaces validated two different audiences | Boot refuses |
AS_AUDIENCE ≠ AUTH_AUDIENCE (AS mode) | Every AS-minted token 401s at the resource server | Boot refuses |
AUTH_ISSUER ≠ AS_ISSUER (AS mode) | The API trusts a party that mints none of its tokens | Boot refuses |
JWKS_URL not the AS's (AS mode) | No signature is ever checked against the keys that signed the token | Boot refuses |
{
"framework": null,
"build": { "env": { "SQLX_OFFLINE": "true" } },
"routes": [
{ "handle": "filesystem" },
{ "src": "/mcp", "dest": "/api/mcp" },
{ "src": "/mcp/(.*)", "dest": "/api/mcp" },
{ "src": "/oauth/(.*)", "dest": "/api/mcp" },
{ "src": "/.well-known/(.*)", "dest": "/api/mcp" },
{ "src": "/(.*)", "dest": "/api/axum" }
]
}api/axum.rs or api/mcp.rs.temper CLI at your instance. The CLI ships unconfigured; temper init performs the setup.temper inithttps://<instance><tenant>.auth0.comclient_idhttps://<instance>/api)~/.config/temper/config.toml looks like:[cloud]
api_url = "https://<instance>"
[auth]
provider = "auth0"
[[auth.providers]]
name = "auth0"
authorize_url = "https://<tenant>.auth0.com/authorize"
token_url = "https://<tenant>.auth0.com/oauth/token"
client_id = "<cli-app-client-id>"
audience = "https://<instance>/api"
callback_url = "https://<instance>/api/auth/cli-callback"
scopes = ["openid", "profile", "email", "offline_access"]temper init \
--no-interactive \
--instance-url https://<instance> \
--auth-domain <tenant>.auth0.com \
--auth-client-id <cli-app-client-id> \
--auth-audience https://<instance>/apiconfig.toml and are suitable for CI/CD and headless agent contexts:| Variable | Purpose |
|---|---|
TEMPER_API_URL | Override the API base URL |
TEMPER_PROVIDER | Override the auth provider name |
TEMPER_TOKEN | Inject a JWT directly — no OAuth flow, no disk state |
TEMPER_TOKEN alongside TEMPER_API_URL and no other configuration is needed. The token is used in-memory; ~/.config/temper/auth.json is not read or written.https://<instance>/mcp. OAuth discovery is served automatically:GET /.well-known/oauth-authorization-server — RFC 8414 metadataGET /.well-known/oauth-protected-resource — RFC 9728 metadataPOST /oauth/register — DCR proxy (returns the pre-registered MCP client_id)claude_desktop_config.json):{
"mcpServers": {
"temper": {
"url": "https://<instance>/mcp"
}
}
}JWKS_URL and checks the instance's one audience — AUTH_AUDIENCE, the same value the REST API validates (see Auth identity). Ensure MCP_CLIENT_ID matches the Auth0 native application registered for your MCP clients and that the client's callback URLs are allowlisted in that Auth0 application.temper-ui SvelteKit app is an optional browser front-end. It deploys as a second Vercel project from the same monorepo (root directory packages/temper-ui) and talks to the API instance you stood up above. It is single-repo and config-driven: an operator points it at their own API origin and their own OIDC issuer entirely through environment variables — no source edits, no fork.hooks.server.ts) to API_BASE_URL, rather than via a hardcoded vercel.json rewrite. Requests to /api/*, /mcp, /oauth/*, and /.well-known/* on the UI origin are forwarded server-side to your API host. Because this is a same-origin proxy (the browser only ever talks to the UI origin), the UI does not require CORS_ORIGINS on the API for its own traffic.⚠️ API_BASE_URLmust be the API backend's own origin, not the UI's public origin. If the UI and API share a public domain (e.g. the UI serves bothtemperkb.ioand proxiestemperkb.io/api), pointingAPI_BASE_URLat that shared domain makes the proxy forward to itself — an infinite loop the platform terminates with508 Loop Detected. Set it to the distinct origin where the API actually runs (its own*.vercel.appURL, or a dedicatedapi.subdomain). The UI guards against this and returns a clear 500 rather than looping, but the value still needs to be correct for the proxy to work.
OIDC_ISSUER's discovery document (/.well-known/openid-configuration), so any OIDC provider works. Logout uses the standard RP-initiated end_session_endpoint.https://<ui-host>/auth/callbackhttps://<ui-host>offline_access scope)| Variable | Required | Notes |
|---|---|---|
API_BASE_URL | Yes | The API backend's own origin (not the UI's public origin — see the loop warning above), e.g. https://<api-host> — used by server loaders and the browser-facing reverse proxy |
OIDC_ISSUER | Yes¹ | Issuer base URL, e.g. https://<tenant>.auth0.com or https://<org>.okta.com/oauth2/<asId>. Discovery resolved from <issuer>/.well-known/openid-configuration |
OIDC_CLIENT_ID | Yes¹ | The UI confidential web-app client_id |
OIDC_CLIENT_SECRET | Yes¹ | The UI confidential web-app client secret |
OIDC_AUDIENCE | Situational | Required for Auth0 (the API identifier); omit for Okta custom auth servers, which carry it implicitly |
APP_URL | Yes | The UI's own public origin, e.g. https://<ui-host> — used to build the redirect and post-logout URIs |
SESSION_SECRET | Yes | ≥32 bytes of entropy (64-char hex or 44-char base64) — derives the JWE session-cookie key |
OIDC_* are unset, the UI falls back to the canonical deployment's AUTH0_DOMAIN / AUTH0_CLIENT_ID / AUTH0_CLIENT_SECRET / AUTH0_AUDIENCE (with OIDC_ISSUER derived as https://<AUTH0_DOMAIN>). Self-hosters should set the OIDC_* variables directly; the fallback exists so the hosted temperkb.io project keeps working unchanged. A non-Auth0 provider is exercised end to end in self-hosting-okta.md.curl https://<instance>/api/healthtemper login/authorize endpoint, the provider redirects the authorization code to /api/auth/cli-callback (a stateless relay), and that relay forwards the code to a short-lived listener on localhost. The CLI then exchanges the code for tokens, prints a confirmation, and caches the token locally. (There is no device-code polling — temper login always uses a browser redirect.)# Create a resource
temper resource create --type session --title "smoke test"
# List to confirm it landed
temper resource list --type session
# Retrieve it by ref (UUID or decorated slug-<uuid>, printed as `ref`)
temper resource show <ref>