> ## Documentation Index
> Fetch the complete documentation index at: https://trysalty.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Three bearer-token paths: sk_live_ API keys, Supabase JWTs, OAuth 2.1 access tokens.

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.

| 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.)    |

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.

```bash theme={null}
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):

```json theme={null}
{
  "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
}
```

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`](/docs/mcp/introduction#option-b-—-static-api-key-recommended-for-dev-/-scripted-setups) — useful when you want to skip the OAuth browser flow during dev or in scripted setups.

Revoke:

```bash theme={null}
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.

### 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](https://datatracker.ietf.org/doc/rfc9728/), 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:

```
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.

## 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

| 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`)                                       |
| 403    | `email_domain_not_allowed` | Signup email is a disposable/temporary mailbox or a non-deliverable domain (POST `/workspaces/bootstrap`) |
| 403    | `insufficient_scope`       | A read-only credential (API key or OAuth token) attempted a write                                         |
| 403    | `workspace_deleted`        | The workspace has been deleted                                                                            |
