Skip to main content
Every Salty API request authenticates with a 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. 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.
Response (the key field is the only place the full secret appears):
Keys default to scopes: ["*"] (full access). Pass read_only: true when creating a key to get scopes: ["read"] — it can read but every write returns 403 insufficient_scope. Finer per-resource scoping may come later. 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:
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.

Accepted signup emails

Signup rejects disposable and temporary mailboxes (Mailinator, Guerrilla Mail, 10 Minute Mail, and ~121k others, including wildcard subdomains like *.33mail.com) along with domains that cannot receive mail at all — example.com and the reserved TLDs .test, .example, .invalid, .localhost, .local. Ordinary free providers are fine: Gmail, Outlook, Yahoo, iCloud, and Proton all work. You do not need a company domain. The check runs twice — once on the signup form for an immediate error, and again on POST /workspaces/bootstrap, which returns 403 email_domain_not_allowed. Only new workspaces are checked, so an existing workspace is never affected if its domain is later added to the list. If a legitimate domain is being rejected, email support and it will be allowlisted.

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 24 hours, refresh tokens 180 days (rotate via grant_type=refresh_token). Reusing an already-rotated refresh token is treated as a theft signal and revokes the entire token family — every access + refresh token minted from that authorization dies at once. Salty’s OAuth endpoints are at:
OAuth-auth’d requests count as agent traffic — they DO consume usage cap.

Passwordless sign-in with passkeys

Admin accounts can register a passkey (WebAuthn — Touch ID, Windows Hello, or a hardware security key) from the dashboard Account page, then sign in with it instead of a password. The login screen offers the passkey automatically (with an explicit Sign in with a passkey button as a fallback), and new accounts are prompted to add one right after signup. A passkey is phishing-resistant and counts as strong authentication on its own, so a passkey sign-in is not additionally challenged for a TOTP code. (Passkeys are bound to the production origin — they work on trysalty.com, not on localhost.)

Two-factor authentication (TOTP)

Admin accounts can enable an authenticator app (TOTP) as a second factor from the Account page. Once enabled, a password login must pass the TOTP challenge (step-up) before reaching the dashboard; a passkey sign-in already satisfies strong auth and skips it. Both apply only to the admin web app — agent traffic uses API keys / OAuth tokens, which have no interactive second factor.

Password requirements

New admin passwords must be at least 10 characters and include lowercase, uppercase, a digit, and a symbol.

Common errors