Skip to content

Instantly share code, notes, and snippets.

@johnlindquist
Last active March 4, 2026 22:36
Show Gist options
  • Select an option

  • Save johnlindquist/e7639091c39fb6e760264a33a6519b11 to your computer and use it in GitHub Desktop.

Select an option

Save johnlindquist/e7639091c39fb6e760264a33a6519b11 to your computer and use it in GitHub Desktop.
Vercel OIDC for AI Gateway: Zero-Config Authentication Writeup

Vercel OIDC for AI Gateway: Zero-Config Authentication

Problem

OpenClaw sandboxes on Vercel needed a manually-configured AI_GATEWAY_API_KEY environment variable to authenticate with Vercel's AI Gateway for LLM inference. This was a deployment friction point — every new deployment or team member needed the key configured.

Solution

Replaced the static API key with Vercel's OIDC (OpenID Connect) token federation. The dashboard's Vercel Functions automatically receive an OIDC JWT that proves they're running on Vercel infrastructure. AI Gateway accepts this JWT as a bearer token — no static key needed.

How It Works

Token Flow

Vercel Function receives request
  → x-vercel-oidc-token header contains JWT
  → getAiGatewayBearerToken() extracts it via @vercel/oidc
  → Token passed to sandbox via Sandbox.create({ env: { AI_GATEWAY_API_KEY: token } })
  → Startup script reads $AI_GATEWAY_API_KEY env var (prefers env over file)
  → OpenClaw gateway uses token for AI Gateway requests

Priority Chain

// packages/dashboard/src/server/ai-gateway-auth.ts
export async function getAiGatewayBearerToken(): Promise<string> {
  // 1. Static key (local dev / explicit override)
  const staticKey = process.env.AI_GATEWAY_API_KEY?.trim();
  if (staticKey) return staticKey;

  // 2. Vercel OIDC token (production/preview)
  const oidcFn = await loadOidcHelper();
  if (oidcFn) {
    const token = await oidcFn();
    if (token) return token;
  }

  throw new Error('AI Gateway auth unavailable');
}

Token Injection Points

Event How Token is Injected
Sandbox create bootstrapOpenClawWorkflow calls getAiGatewayBearerTokenOptional() and passes to setupOpenClaw
Sandbox restore All restore callers pass token via Sandbox.create({ env: { AI_GATEWAY_API_KEY: token } })
Token refresh Hourly cron route /api/cron/token-refresh writes fresh token to sandbox file + restarts gateway
Startup script Prefers $AI_GATEWAY_API_KEY env var over file, so fresh tokens override stale snapshot data

OIDC Token Details

  • Format: JWT signed by Vercel (iss: https://oidc.vercel.com/{team})
  • Lifetime: ~1 hour (prod/preview), ~12 hours (dev)
  • Scope: owner:{team}:project:{project}:environment:{env}
  • Delivery: x-vercel-oidc-token request header (Functions), VERCEL_OIDC_TOKEN env var (builds)

Token Refresh (Cron)

OIDC tokens expire in ~1 hour. The cron job at /api/cron/token-refresh runs hourly:

  1. Gets fresh OIDC token via getAiGatewayBearerToken()
  2. Lists all running OpenClaw sandboxes from Redis
  3. For each: writes fresh token to .ai-gateway-api-key file + restarts gateway
  4. Reports per-sandbox outcomes

Files Changed

New

  • packages/dashboard/src/server/ai-gateway-auth.ts — OIDC/static key helper
  • packages/dashboard/app/api/cron/token-refresh/route.ts — Hourly token refresh
  • packages/dashboard/test/cron/token-refresh.test.ts — Token refresh tests

Modified

  • packages/vercel-sandbox/src/sandboxes/service.tsrestoreSandbox accepts env option, createSandbox accepts aiGatewayApiKey
  • packages/vercel-sandbox/src/sandboxes/openclaw-setup.ts — Startup script prefers env var over file
  • packages/dashboard/app/api/sandboxes/route.ts — Uses OIDC for pi/openclaw creation
  • packages/dashboard/app/api/proxy/.../route.ts — Injects OIDC token on restore
  • packages/dashboard/app/api/sandboxes/[key]/restore/route.ts — Injects OIDC token
  • packages/dashboard/app/api/health/route.ts — Detects OIDC availability
  • packages/dashboard/src/server/workflows/openclaw/bootstrapOpenClawWorkflow.ts — Uses OIDC helper
  • Plus: fleet lifecycle, cron channel-maintenance, snapshot restore routes

Dependencies

  • Added @vercel/oidc@3.2.0 to dashboard
  • Upgraded @vercel/sandbox from 1.6.0 → 1.8.0 (for Sandbox.create({ env }) support)

Verification

Tested end-to-end on production:

  1. ✅ Removed AI_GATEWAY_API_KEY from Vercel env vars
  2. ✅ Health check reports aiGatewayApiKey: true (via OIDC detection)
  3. ✅ Restored sandbox amber-frost-25 — gateway process confirmed running with OIDC JWT
  4. ✅ Chat completions work: AI responds correctly to messages
  5. ✅ Gateway process env shows JWT token (eyJhbGciOiJSUzI1Ni...) not static key

Backwards Compatibility

  • If AI_GATEWAY_API_KEY is set, it's used (priority 1)
  • If not, OIDC token is tried (priority 2)
  • Local development: set AI_GATEWAY_API_KEY in .env.local or run vercel env pull
  • Existing snapshots with baked-in keys continue to work (startup script falls back to file)

Prerequisites

  • Vercel OIDC must be enabled in project settings (it was already enabled for this project)
  • @vercel/oidc package installed
  • @vercel/sandbox >= 1.8.0 for env parameter support
## Post-Deploy Fixes (lessons learned)
### 1. Workflow step context has no OIDC headers
The bootstrap workflow's `setupOpenClawStep` uses `'use step'` which runs in a separate execution context. The OIDC token (`x-vercel-oidc-token` header) is only available during the original HTTP request, not in workflow steps.
**Fix:** Capture the token in the request handler and pass it as a parameter through `bootstrapOpenClawWorkflow → setupOpenClawStep`.
### 2. Don't bake OIDC JWTs into OpenClaw config
The `openclaw.json` config's `models.providers.openai.apiKey` had the raw API key baked in. With OIDC, this was a 1088-char JWT that OpenClaw may reject.
**Fix:** Use `'sk-placeholder'` in the config. The real token is passed via `OPENAI_API_KEY` env var through the startup script's `env(1)` command.
### 3. OpenClaw does NOT support `${ENV_VAR}` in config
Despite documentation suggestions, OpenClaw config files don't perform environment variable substitution. `${OPENAI_API_KEY}` is treated as a literal string.
### 4. Guard `$AI_GATEWAY_API_KEY` for `set -u`
The startup script uses `set -euo pipefail`. When the workflow step gets a sandbox handle via `Sandbox.get()` (not the original `Sandbox.create({ env })` instance), the env defaults aren't inherited. Using `${AI_GATEWAY_API_KEY:-}` safely handles unset vars, falling back to reading the key file.
### 5. `Sandbox.create({ env })` env defaults don't survive `Sandbox.get()`
Environment variables set via `Sandbox.create({ env: {...} })` are only available for `runCommand` calls on that specific instance. Getting a new handle via `Sandbox.get({ sandboxId })` does NOT inherit those env defaults.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment