Skip to main content
Every Salty API request authenticates with a bearer token:
Authorization: Bearer <token>
The token’s prefix tells the middleware which path to use. All three paths converge on a workspace + (optional) user context, then run the usual usage-log / rate-limit / usage-cap / idempotency stack.
PrefixTypeIssued byWho uses it
sk_live_…API keyPOST /api-keysagents (sk_live + secret hash → workspace)
eyJ… (JWT)Supabase Auth/signup + /loginhumans in the web admin UI
salty_oat_…OAuth 2.1 access tokenPOST /oauth/tokenMCP clients (Claude/Cursor/ChatGPT/etc.)
Internally each path sets c.var.authKind to api_key | jwt | oauth. The usage-cap middleware meters agent traffic (api_key + oauth) but exempts admin traffic (jwt) — so a customer over their cap can still cancel/downgrade.

1. API keys (sk_live_…)

Long-lived. Mint via POST /api-keys; only the first 16 chars (sk_live_xxxxxxxx) are stored as a non-secret lookup prefix and the rest is argon2-hashed. Full key is shown ONCE. Lose it → revoke (DELETE /api-keys/:id) and create a new one.
curl -X POST $SALTY_API/api-keys \
  -H "Authorization: Bearer $SALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"agent-1"}'
Response (the key field is the only place the full secret appears):
{
  "id": "08e7e7d4-31ff-4456-bd99-731252edb54c",
  "prefix": "sk_live_abcd1234",
  "name": "agent-1",
  "scopes": ["*"],
  "key": "sk_live_abcd1234...",
  "created_at": "2026-05-24T02:00:00Z",
  "revoked_at": null
}
All keys carry scopes: ["*"] in v1 — full workspace access. Scoped keys (read-only, per-resource, etc.) ship in v1.1. API keys also work in MCP clients (Claude Desktop, Cursor) via mcp-remote --header — useful when you want to skip the OAuth browser flow during dev or in scripted setups. Revoke:
curl -X DELETE $SALTY_API/api-keys/<id> \
  -H "Authorization: Bearer $SALTY_API_KEY"
Returns 204. Revoked keys immediately fail auth with 401 invalid_api_key. The row is kept for audit (revoked_at set).

2. Supabase JWTs (admin UI)

When a human signs up at /signup, verifies email, and logs in, the apps/web frontend sets Supabase Auth cookies. Every request from the web admin (/api, /records, /pricing) sends the access JWT to the API in the same Authorization: Bearer header. JWT requests authenticate the user, then look up workspace_members to resolve their workspace. The special-cased path POST /workspaces/bootstrap is the only endpoint a JWT-auth’d user can hit before having a workspace — it idempotently creates one and mints the first sk_live_ key. The admin /api page calls this on every load so a fresh signup lands straight on a usable workspace. JWT-auth’d requests are exempt from the usage cap (so customers who hit their cap can still raise it or cancel) but still subject to the per-workspace rate limit.

3. OAuth 2.1 + PKCE (MCP clients)

MCP clients (Claude.ai, Cursor, ChatGPT, Windsurf, Claude Desktop) discover Salty’s auth server via RFC 9728 protected-resource metadata, then run the standard OAuth 2.1 + PKCE authorization-code flow:
  1. Client GETs /.well-known/oauth-protected-resource (from MCP server’s WWW-Authenticate header).
  2. Client GETs /.well-known/oauth-authorization-server (RFC 8414 metadata).
  3. Client POSTs /oauth/register (RFC 7591 dynamic client registration) → gets a client_id (public PKCE client, no secret).
  4. Client redirects user’s browser to /oauth/authorize?client_id=…&redirect_uri=…&code_challenge=…&state=….
  5. Salty 302-redirects to /oauth/consent on apps/web where the user clicks Approve.
  6. The browser returns to the client’s redirect_uri with ?code=…&state=….
  7. Client POSTs /oauth/token with the code + code_verifier → gets a salty_oat_… access token + refresh token.
Access tokens live 90 days, refresh tokens 180 days (rotate via grant_type=refresh_token). Salty’s OAuth endpoints are at:
GET  /.well-known/oauth-authorization-server   # RFC 8414 metadata
GET  /.well-known/oauth-protected-resource     # RFC 9728 metadata
POST /oauth/register                            # RFC 7591 dynamic client registration
GET  /oauth/authorize                           # 302 → /oauth/consent on apps/web
POST /oauth/token                               # authorization_code or refresh_token
OAuth-auth’d requests count as agent traffic — they DO consume usage cap.

Common errors

StatusCodeReason
401missing_authNo Authorization: Bearer … header
401invalid_auth_headerHeader present but malformed
401invalid_api_keysk_live_… not found, revoked, or hash mismatch
401invalid_jwtSupabase JWT expired or signature invalid
401invalid_oauth_tokensalty_oat_… not found, expired, or revoked
401workspace_not_foundToken resolves but its workspace was deleted
403no_workspaceJWT-auth’d user has no workspace yet (POST /workspaces/bootstrap)