| Prefix | Type | Issued by | Who uses it |
|---|---|---|---|
sk_live_… | API key | POST /api-keys | agents (sk_live + secret hash → workspace) |
eyJ… (JWT) | Supabase Auth | /signup + /login | humans in the web admin UI |
salty_oat_… | OAuth 2.1 access token | POST /oauth/token | MCP clients (Claude/Cursor/ChatGPT/etc.) |
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.
key field is the only place the full secret appears):
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:
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:- Client GETs
/.well-known/oauth-protected-resource(from MCP server’sWWW-Authenticateheader). - Client GETs
/.well-known/oauth-authorization-server(RFC 8414 metadata). - Client POSTs
/oauth/register(RFC 7591 dynamic client registration) → gets aclient_id(public PKCE client, no secret). - Client redirects user’s browser to
/oauth/authorize?client_id=…&redirect_uri=…&code_challenge=…&state=…. - Salty 302-redirects to
/oauth/consentonapps/webwhere the user clicks Approve. - The browser returns to the client’s
redirect_uriwith?code=…&state=…. - Client POSTs
/oauth/tokenwith thecode+code_verifier→ gets asalty_oat_…access token + refresh token.
grant_type=refresh_token).
Salty’s OAuth endpoints are at:
Common errors
| Status | Code | Reason |
|---|---|---|
| 401 | missing_auth | No Authorization: Bearer … header |
| 401 | invalid_auth_header | Header present but malformed |
| 401 | invalid_api_key | sk_live_… not found, revoked, or hash mismatch |
| 401 | invalid_jwt | Supabase JWT expired or signature invalid |
| 401 | invalid_oauth_token | salty_oat_… not found, expired, or revoked |
| 401 | workspace_not_found | Token resolves but its workspace was deleted |
| 403 | no_workspace | JWT-auth’d user has no workspace yet (POST /workspaces/bootstrap) |