# Authentication

Source: https://molted.email/docs/authentication

Session auth for the portal UI and Bearer API keys for the sending API.

Molted Email uses two authentication methods depending on the context.

## Session cookies (Portal UI)

When you sign in at [molted.email](https://molted.email), a session cookie is set. This cookie authenticates all portal requests: Home, mailboxes, and Settings (domains, plan and API keys).

No action is required on your part. The session is managed automatically by the portal.

### Email verification

When you sign up in the portal, we email you a link to verify your address. Opening it confirms your email and signs you in. Verification is never needed to log in, and accounts created by an agent (the CLI, MCP or the API) don't get a verification email.

Links work for 24 hours. To get a new one, use **Send verification email** in **Settings**. You can ask for one a minute, up to 5 a day.

### Forgot or reset your password

1. On the sign-in page choose **Forgot password?** (or open `molted.email/forgot-password`), type the email address of your account and press **Send reset link**. The page gives the same answer whether or not the address has an account: it says a link was sent if an account exists.
2. Open the email titled "Reset your Molted Email password". The link works for 1 hour.
3. Type the new password twice on the **Set new password** page. It must be at least 8 characters.
4. When the page says **Password updated**, press **Sign in**.

A link that is missing, expired or already used is refused; ask for a new one on the forgot-password page. A reset signs the account out of every other browser and out of the phone app, so sign in again wherever you need to. Your API keys are not touched and keep working. Apps connected through OAuth keep their access until you revoke them in **Settings**.

### Workspaces and `X-Molted-Tenant`

One person can belong to several workspaces: their own, and any they claimed from an agent or were added to. A workspace's API keys are scoped to that workspace alone, so the switching below applies to people, not keys.

- **In the portal**, open the **⌄** next to the workspace name in the top bar, which lists your workspaces, and pick one. The portal reloads Home in that workspace. It remembers your choice in a functional cookie, `molted_tid`, which is only ever a preference: it is checked against your memberships on every request, and a stale or foreign value falls back to the workspace you own (the oldest, if you own several).
- **In the phone app**, there is no remembered workspace. The app names its workspace on every request with the `X-Molted-Tenant` header, set to a workspace id. `GET /v1/me/tenants` lists the workspaces a person can use, as `{ id, name, status, role }` plus an opaque `pushRef`.

| Status | Error | Cause |
| - | - | - |
| `400` | `tenant_header_required` | A phone-app request without `X-Molted-Tenant`. |
| `400` | `invalid_tenant_header` | The header is not a single workspace id. |
| `403` | `not_a_member` | The person is not a member of exactly that workspace. There is no fallback to their own. |

Workspace roles apply inside each workspace: some actions, such as renaming the workspace or deleting it, are for the owner only.

## Bearer API keys (Sending API)

Every agent API request carries a Bearer token in the `Authorization` header:

```bash
Authorization: Bearer YOUR_API_KEY
```

### Which host to call

`https://mailbox.molted.email` is the default for agents, and the examples in these docs use it. It serves every agent route, including the routes only the mailbox serves (`/v1/agent/spam-filter/rules`, the `/v1/agent/contacts` and `/v1/agent/folders` aliases, the deprecated `intent-correction` alias and `/v1/agent/events/inbound`), and fills in your `tenantId` from the key, so you never send one. The CLI and the MCP server go through it too.

`https://api.molted.email` serves the same agent routes except the mailbox-only ones, but needs your `tenantId` on every call (query string for a `GET`/`DELETE`, JSON body for a `POST`/`PUT`/`PATCH`). It stays the host for signing in and the portal-session routes (`/api/auth/*`, `/v1/me/*`), for the links in emails (unsubscribe, list confirmation) and inbound hook URLs (`/v1/hooks/:token`), and for the operator-only `/v1/admin/*` routes.

On the mailbox host, a `POST` that has no other fields still sends `{}` as its JSON body, so the service has a body to put your `tenantId` in.

### Managing API keys

1. Go to **Settings > Access** in the portal (`/app/settings#access`).
2. Click **Create key**, give it a label, and choose all mailboxes or specific ones with their own permissions.
3. Copy the key immediately -- it is displayed only once and cannot be retrieved later.
4. To revoke a key, click **Revoke** next to the key in the list.

### Mailbox-scoped keys

API keys can be scoped to specific mailboxes with granular permissions:

```bash title="curl"
curl -X POST https://api.molted.email/v1/me/keys \
  -H "Cookie: YOUR_SESSION_COOKIE" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "support-inbox-only",
    "scopeAllMailboxes": false,
    "mailboxScopes": [
      { "mailboxId": "MAILBOX_UUID", "permissions": ["read", "send"] }
    ]
  }'
```

Set `scopeAllMailboxes: true` for a key with full access to all mailboxes (an `admin` key). Leaving it out means `true` when the request names no mailboxes, and `false` when it sends `mailboxScopes`.

A key also carries top-level scopes, derived from what it was minted with: an all-mailbox key gets `admin`; a mailbox-scoped key gets `read` for any permission, `automation` when it holds `send` or `manage` somewhere, and `manage` when it holds `manage`. Workspace-wide writes check these (see [Workspace-wide routes](https://molted.email/docs/api-keys.md#workspace-wide-routes)). In the portal, the workspace owner's session acts as `admin`; any other member's session is `read` for workspace-wide changes.

**Per-mailbox permissions:**

| Permission | Grants |
| - | - |
| `read` | Read threads, messages, contacts, and metadata on the mailbox. |
| `send` | Send new emails, reply in threads, and schedule follow-ups from the mailbox. Does **not** grant read. |
| `manage` | Full control: read, send, and modify rules, folders, and mailbox settings. Implies `read` + `send`. |

A key with `send` only can send from the scoped mailbox but cannot list or fetch its threads -- useful for outbound-only agents (notifications, campaigns, alerts). Requests to other mailboxes return `403 mailbox_scope_denied`.

Routes that act on the whole workspace (domains, providers, billing, alert destinations, inbound hooks, account and safety settings, writes to contacts, lists and segments, removing suppressions, granting consent, agent coordination, the ingest routes, and writes to journeys, templates and experiments) also return `403 mailbox_scope_denied` to a mailbox-scoped key, whatever its permissions. Tightening changes (adding a suppression, withdrawing consent, unsubscribing) stay open to a scoped key with `send` or `manage` somewhere. See [Workspace-wide routes](https://molted.email/docs/api-keys.md#workspace-wide-routes).

The scope is the same whichever way the key reaches us: directly against the API (including the mailbox, thread, outbound, rule, override and alert routes under `/v1/agent/*`, which the API serves from the mailbox service -- the bulk thread actions `archive`, `unarchive`, `trash`, `restore` and permanent delete resolve each thread's mailbox and check it too), or through the CLI and MCP server (which go through the mailbox service). A send that names no `mailboxId` goes out from the key's own mailbox, and the approval queue (`GET /v1/send/approvals`) lists only the key's mailboxes.

### Minting scoped keys as an agent

An existing agent can mint further scoped keys with its own Bearer token (no
session cookie needed) -- useful for handing a sub-agent a narrow key. The agent
endpoint `POST /v1/agent/keys` takes the same shape (on `https://api.molted.email`
it also needs your `tenantId` in the body; the mailbox host and the CLI fill it in):

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/keys \
  -H "Authorization: Bearer mm_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "label": "support-inbox-only",
    "scopeAllMailboxes": false,
    "mailboxScopes": [
      { "mailboxId": "MAILBOX_UUID", "permissions": ["read", "send"] }
    ]
  }'
```

```bash title="CLI"
molted auth keys create --label support-inbox-only \
  --mailbox MAILBOX_UUID --permissions read,send
molted auth keys create --label triage --scope MBX_A:read,send --scope MBX_B:read
molted auth keys create --label ops --scope-all
molted auth keys update <id> --mailbox MAILBOX_UUID --permissions manage
molted auth keys list
molted auth keys revoke <id>
```

`--permissions` defaults to `read,send`; `--scope <mailboxId>[:<perms>]` is repeatable, for several mailboxes at once. `--scope-all` can't be combined with the mailbox flags, and `update` changes the scope only (it has no `--label`). There is no MCP tool for key management.

A token can only scope a key to mailboxes its **own tenant** owns; scoping to a
mailbox owned by another tenant is rejected (`403 mailbox_not_owned: <ids>` on
create, `400 Mailbox IDs do not belong to this tenant: <ids>` on update, and
`400 mailbox_scope_invalid` on `POST /v1/me/keys`). The raw key is returned once in the `rawKey` field -- store it
immediately. A key may only hand out what it has: widening a key, or touching a
key it didn't mint, is a human decision (`403 human_decision_required`, see
[What a key may do to other keys](https://molted.email/docs/api-keys.md#what-a-key-may-do-to-other-keys)).

For the full programmatic key-management guide (listing, re-scoping, rotating,
revoking, and the least-privilege pattern for apps), see
[Managing API keys programmatically](https://molted.email/docs/api-keys.md).

## Programmatic signup

> **Note:** Signup is open. If it's ever paused, `POST /api/auth/sign-up/email` returns `403 { "code": "SIGNUPS_CLOSED", "waitlistUrl": "..." }`: hand a person the `waitlistUrl`.

Agents can create accounts and provision API keys without the portal UI. To join a workspace someone already runs, don't sign up: [adopt](https://molted.email/docs/adoption.md) into it with an invite token or the device flow (`molted adopt claim` / `molted adopt request`).

### Step 1: Create an account

```bash title="curl"
curl -X POST https://api.molted.email/api/auth/sign-up/email \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{
    "name": "Ada Agent",
    "email": "agent@example.com",
    "password": "secure-password-here"
  }'
```

This creates a user account and automatically provisions a tenant (named after the first word of `name`: `Ada's workspace`), a default mailbox on the shared `agent.molted.email` domain, and the default `_default` transactional template. The session cookie is saved to `cookies.txt`. An account created through the API, CLI or MCP is **agent-owned**: no person owns the workspace until a human claims it (see [Links for your human](#links-for-your-human-take-over-claim-or-sign-in)), and the Free plan stays closed to it until then. In production, sign-up allows 3 attempts an hour per IP address. The CLI does steps 1 to 3 in one command: `molted auth signup`.

### Step 2: Get your tenant ID

```bash title="curl"
curl https://api.molted.email/v1/me/tenant \
  -b cookies.txt
```

```json title="Response (trimmed)"
{
  "id": "tenant-agent-a1b2c3d4",
  "name": "Ada's workspace",
  "status": "active",
  "billing_plan": "trial",
  "created_at": "2026-10-21T09:00:00.000Z"
}
```

The ID is `tenant-<email local part>-<8 random characters>`, so it is not anonymous. `status` is the account's state (`active` while it can be used); the plan is `billing_plan`, which starts at `trial` with sends blocked until a plan is activated (see [Billing & Plans](https://molted.email/docs/billing.md)). The owner also sees billing and settings fields such as `trial_ends_at`, `has_payment_method`, `human_gate_mode` and `cooldown_minutes`.

### Step 3: Create an API key

```bash title="curl"
curl -X POST https://api.molted.email/v1/me/keys \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{
    "label": "default",
    "scopeAllMailboxes": true
  }'
```

```json title="Response"
{
  "id": "key-uuid",
  "keyPrefix": "mm_live_...",
  "label": "default",
  "status": "active",
  "scopeAllMailboxes": true,
  "createdAt": "2026-10-21T09:00:00.000Z",
  "rawKey": "mm_live_..."
}
```

Save `rawKey` immediately -- it is only returned once. Use it as your `Authorization: Bearer` token for all subsequent API calls.

## Links for your human: take over, claim or sign in

When your agent needs a person (to pick a plan, approve a send, manage domains, or get the Free plan), it runs one command: `molted auth login-link`. Molted picks the path from what it knows. Nothing it returns signs anyone in: the person always uses their own password.

- **No person owns the workspace yet, and the agent's account email is an outside inbox** (the agent signed up with its person's own address, say): Molted **emails that address a take-over link** and returns no link at all. Your human opens the email, sets their own password, and the account is theirs. See [Taking over an account](#taking-over-an-account).
- **No person owns the workspace yet, and the account email is one of the agent's Molted mailboxes:** the link is a **claim link**. Your human opens it, signs in or creates an account with their own email, and claims the workspace. They become its owner; the agent keeps working with its keys.
- **A person owns it:** the link is the plain portal URL. Your human signs in with their own login.

For the Free plan, an agent runs `molted auth login-link --next "/app/settings#plan"` and follows what it returns; afterwards `molted billing activate-free` works with the agent's existing key.

### CLI

```bash title="CLI"
molted auth login-link
molted auth login-link --next "/app/settings#plan"
```

The command uses your active profile credentials. `--next` is where your human lands afterwards (a path in the app). The CLI adds a `note` to the API's answer explaining what to do with the link; the API itself returns `kind`, `url` and `expiresAt`. There is no MCP tool for this: MCP clients follow `setup_status`, whose `next.request` names this call when a human is needed.

```json title="Response (no person owns the workspace, the account email is an outside inbox)"
{
  "kind": "takeover",
  "url": null,
  "sentTo": "m•••@gmail.com",
  "expiresAt": "2026-10-24T14:15:00.000Z",
  "alreadySent": false,
  "note": "Molted emailed a take-over link to m•••@gmail.com. There is no url for you on purpose: ..."
}
```

```json title="Response (no person owns the workspace, the account email is a Molted mailbox)"
{
  "kind": "claim",
  "url": "https://molted.email/claim/AbC123...",
  "expiresAt": "2026-10-16T14:15:00.000Z",
  "note": "A claim link: give the url to your human. ..."
}
```

```json title="Response (a person owns the workspace)"
{
  "kind": "portal",
  "url": "https://molted.email/app",
  "expiresAt": null,
  "note": "The portal: a person already owns this workspace. ..."
}
```

### API

```
POST https://mailbox.molted.email/v1/agent/login-token
```

Requires [Bearer authentication](https://molted.email/docs/authentication.md#bearer-api-keys-sending-api) with
an **admin, all-mailbox key**: a claim makes a person the owner of the whole
workspace, so the calling key must already have that reach. A key scoped to one
or more specific mailboxes gets `403 { "message": "mailbox_scope_denied" }`; a
key without `admin` scope gets `403 { "message": "insufficient_scope" }`.

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `next` | `string` | No | Where your human lands afterwards: a path in the app (`/app` or `/app/...`), or a URL on the portal whose path is `/app` or `/app/...` (it is reduced to the path, query and fragment). At most 512 characters; control characters and backslashes are refused. Anything else is `400 { "error": "invalid_next", "message": "..." }`. Default `/app`. |

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/login-token \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"next": "/app/settings#plan"}'
```

### Response fields

| Field | Type | Description |
| - | - | - |
| `kind` | `"takeover"`, `"claim"` or `"portal"` | What happened: a take-over email went out, or the link is a claim link or the plain portal URL. |
| `url` | `string` or `null` | The link to give your human. It carries no sign-in token. `null` for `takeover`: the emailed link grants the account, so it never comes back to the agent. |
| `expiresAt` | `string` or `null` | When the take-over link (24 hours) or claim link (7 days) stops working. `null` for a portal link, which doesn't expire. |
| `sentTo` | `string` | `takeover` only: the address the email went to, masked (`m•••@gmail.com`). |
| `alreadySent` | `boolean` | `takeover` only: `true` when an email from the last 15 minutes answered and no new one went out. |

### Taking over an account

An agent that signed up with its person's own email holds an account the person can neither claim (the address is taken) nor use (the agent chose the password). The take-over email fixes both:

1. `login-token` (or the person, signed in to the portal with that account, choosing **I'm a person**, or **Take over this account** on a claim page) sends a take-over link to the account's address.
2. Your human opens it in their browser and sets their own password.
3. In one step: the password the agent chose stops working, the account becomes a verified person's, every session and phone signed in to it is signed out, and every older take-over or password-reset link of the account stops working. The agent's API keys keep working.
4. They are signed in with the new password and land on `next` (default **Settings > Plan**, where Free is one click).

Afterwards `login-token` answers `kind: "portal"`. The take-over link works **once**, for **24 hours**, and only in a browser. It is never sent to an address Molted receives mail for (anything on the shared `agent.molted.email` domain, a custom domain receiving through Molted, a Molted mailbox address, plus-tags and letter case included) or to a disposable address; those get a claim link instead, and the rule is checked again when the link is used. A repeat request within 15 minutes sends nothing and answers `alreadySent: true`; an account gets at most 5 take-over emails a day.

| Route | Auth | What it does |
| - | - | - |
| `POST /v1/me/principal/upgrade` | Portal session of an agent account | **I'm a person**: emails the take-over link to the account's address. `{ sent, sentTo, expiresAt, alreadySent }`. `409 already_human`, `403 email_not_allowed` or `disposable_email_not_allowed`. |
| `POST /v1/takeover/inspect` | None (the token) | `{ token }`: whether the link still works, `{ sentTo }` (masked). `400 invalid_token`, `410 token_used` or `token_expired`, `403 email_not_allowed`. Changes nothing. |
| `POST /v1/takeover/confirm` | None (the token), browser only, from the portal | `{ token, password, next? }`: sets the password, makes the account the person's, and signs them in. `{ next, signedIn }`. The errors above plus `400 password_too_short` / `password_too_long`, `403 browser_required` / `invalid_origin`. |

The old **I'm a person** confirm link (`/v1/principal/upgrade/confirm`, from before take-over links) no longer does anything: it shows a page asking for a new link, because it marked the account as a person's without replacing the agent's password.

### Claiming a workspace

1. Your human opens the claim link. The page shows the workspace's name, which agent key asked, and what claiming means.
2. They sign in, or create an account with their own email. Creating an account through a claim link works even while sign-ups are on the waitlist, and it creates no extra workspace. It asks for no password: we email a link to set one, so the password is always chosen by whoever reads that inbox. Setting it signs them in.
3. They click **Claim**. This needs a verified email address and a password sign-in from the last 10 minutes; an older session is asked to sign in again.
   The account must use an email address of their own: an address that receives mail through Molted (anything on the shared `agent.molted.email` domain or its subdomains, any workspace's verified custom domain with receiving on, or any Molted mailbox address) can't be used to claim, plus-tags included.
4. They land where the agent asked (`next`), with the claimed workspace open, and the workspace switcher shows it next to any workspace of their own.

After the claim, `login-token` answers `kind: "portal"`.

### Behavior and limits

- A claim link works for **7 days** and **once**, and it can create one account. A new link doesn't cancel older ones, but once a person owns the workspace every outstanding link stops working.
- Revoking the agent key that asked for a link also kills the link.
- A workspace gets at most **5 claim links an hour** (a rolling hour); after that the call answers `429 { "error": "rate_limited", "message": "...", "retryAfterSeconds": ... }` with a `Retry-After` header. Reuse a link you already have.
- An expired, used or unknown link shows its own page, and nothing happens.

### Claim API

The claim page in the portal drives these routes; they are listed for completeness, since an agent only ever calls `login-token`.

| Route | Auth | What it does |
| - | - | - |
| `GET /v1/claim/:code` | None | `{ workspaceName, requestedBy }` for the claim page. `404 claim_not_found`, `410 claim_expired`, `410 claim_used`. |
| `POST /v1/me/claim/:code` | Portal session (from the portal origin) | Claims the workspace: `{ tenantId, workspaceName, next }`. Refused with `403` `human_required`, `email_verification_required`, `fresh_sign_in_required` (no password sign-in in the last 10 minutes), `read_only_device` (the phone app) or an address Molted receives mail for, plus the `404`/`410` above. |
| `POST /v1/me/claim/:code/verification-email` | Portal session | Resends the verification email (once a minute, 5 a day). |
| `POST /v1/claim/:code/set-password-link` | None | `{ email }`: emails a set-password link to an account created through this claim link. Always answers `{ "sent": true }`. The link works for 24 hours; at most 3 an hour. |
| `POST /v1/claim/:code/set-password` | None | `{ token, password }`: sets the password and signs in. Answers `{ next, workspaceName, signedIn }`. `400` `invalid_token`, `password_too_short` or `password_too_long`. |

An account created through a claim link (the portal sends the code with `POST /api/auth/sign-up/email`) gets no session and no password of its own: any password sent is replaced, and the set-password email is how its owner gets in. Only one account can be created per link.

### Security notes for claim links

- A claim link grants nothing on its own. Only a person (not an agent's account) can claim, with a verified email address Molted doesn't receive mail for, and only right after typing their password, so an agent that opens its own link gets neither a session nor ownership.
- What this can't stop: an agent that controls an email account outside Molted could sign up "as a person" with it. Passkeys or a registered device are the real fix, and are planned.
- Still share it only with the person who should own the workspace: whoever claims it first becomes an owner.
- Older login links (`/auth/token-login?token=...`, from before claim links) no longer sign anyone in: they open the login page, and the sessions they created were signed out when claim links shipped.
- A password reset signs the account out of every other browser and the phone app. Apps connected through OAuth keep their access until you revoke them in Settings.

## Security notes

- An all-mailbox key can do anything in your tenant, and a scoped key everything its mailboxes allow. Treat keys like passwords.
- Store keys in environment variables or a secrets manager. Never commit them to source control.
- Rotate keys periodically. Revoke any key you suspect has been compromised.
- Each key is scoped to a single tenant.
- A suspended account cannot sign in, and any existing session is rejected with `403 { "error": "USER_SUSPENDED" }` on its next request. API keys are tenant-scoped, so suspending a user does not revoke that user's tenant's API keys.
