# Sending a credential

Every `/v0` request and every `/mcp` request needs a credential. There are three kinds:

| Credential              | Looks like                                    | Where it works                        |
|------------------------|-----------------------------------------------|--------------------------------------|
| API key                | `mm_live_` + 32 base62 characters             | `/v0/*`, `/mcp`, `/inbound/raw`    |
| Agent access token     | a JWT (`eyJ...`) from `POST /oauth2/token`  | `/v0/*`, `/mcp`                     |
| Dashboard session       | the `mm_session` cookie                       | the dashboard; also accepted on `/v0/*`, never on `/mcp` |

Integrations should use API keys. The session cookie is set when a person signs in to the dashboard and exists for the dashboard’s own use.

## Sending a credential

```shell
curl https://api.machinemail.dev/v0/inboxes \
  -H "Authorization: Bearer mm_live_4f8Kq2ZbX9mN3pR7tV1wY6cH0jL5sD8a"
```

The server reads, in order:

1. `Authorization: Bearer <token>` (the `Bearer` keyword is case-insensitive).
2. `?token=<token>` in the query string, only on `GET /v0/events/stream` and the `/v0/ws` WebSocket upgrade, for clients that cannot set headers.
3. The `mm_session` cookie.

When a bearer or query token is present the cookie is ignored, so a bad token returns `401` even if a valid cookie is also sent. There is no `X-API-Key` header and no Basic auth.

A missing, unknown, revoked or expired credential returns:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.machinemail.dev/.well-known/oauth-protected-resource"
Content-Type: application/json

{"error":{"type":"unauthorized","message":"Missing or invalid API key"}}
```

The `resource_metadata` URL is the starting point for agents that onboard themselves with [auth.md](/content/docs/reference/authentication#agent-access-tokens/index.html).

## API keys

- Format: `mm_live_` followed by 32 characters from `0-9A-Za-z`, 40 characters in all.
- The first 12 characters (for example `mm_live_4f8K`) are stored as the key’s `prefix` and shown in listings.
- Only a SHA-256 hash of the key is stored. The full key is returned once, in the response to `POST /v0/api-keys`.
- A key can have an `expires_at`. From that time on it returns `401` with the message `This API key expired at <time>. Create a new one.` A key created with an expiring key never outlives it. See [Expiry](/content/docs/concepts/api-keys#expiry/index.html).
- Deleting a key (`DELETE /v0/api-keys/{api_key_id}`) revokes it immediately.

> Note
> The key format reserves an `mm_test_` prefix, and the OpenAPI document mentions it for a sandbox. The API does not issue `mm_test_` keys: every key is `mm_live_`. The sandbox for agents is the anonymous [agent registration](/content/docs/reference/authentication#agent-access-tokens/index.html).

The first time a person without an organization signs in to the dashboard, MachineMail creates one, a Default Pod and an organization key named “Default key”. The dashboard fetches that key once with `GET /auth/first-key` within 10 minutes of sign-in.

### Key scope

Every key belongs to one organization and has one of three scopes:

| Scope         | Created with                    | Sees                                              |
|---------------|----------------------------------|--------------------------------------------------|
| Organization   | `POST /v0/api-keys`             | everything in the organization                     |
| Pod           | `POST /v0/pods/{pod_id}/api-keys`| that pod’s inboxes, threads, drafts, webhooks, domains and lists |
| Inbox         | `POST /v0/inboxes/{inbox_id}/api-keys`| that one inbox                                   |

The path you create the key on sets its scope; the request body does not. A resource outside the key’s scope returns `404`, as if it did not exist.

Rules for creating keys:

- Only organization-scoped credentials can create API keys (`403 Only organization-scoped credentials can create API keys`).
- A key with a permission list cannot create a key without one (or with `*`), and cannot grant permissions it does not hold (`403`, `param: permissions`). The same check applies to `PATCH /v0/api-keys/{api_key_id}`.
- `inbound_raw` can only be granted from the dashboard (a signed-in person) or by a key that itself has `inbound_raw`. A full-access key cannot grant it, to another key or to itself.

Some actions also need a wider scope than the permission alone:

| Action                                       | Requirement                   |
|---------------------------------------------|-------------------------------|
| Create or delete an inbox                   | not inbox-scoped              |
| Add a domain                                | not inbox-scoped              |
| Create a pod, update the organization, remove a suppression, list organization members | organization-scoped           |
| List or revoke agent registrations           | organization-scoped, not a sandbox token |

### Permissions

A key with no `permissions` list can do everything its scope allows, except `POST /inbound/raw`. A key with a list can only call routes whose permission is on it; any other route returns `403` with `API key lacks the <permission> permission`.

`*` in a list stands for every permission except `inbound_raw`, so `{"*": true, "inbound_raw": true}` is full access plus raw injection.

`inbound_raw` is never implied: not by a key without a list, and not by `*`. It must be granted by name. Injected mail looks received, so keep it to test and CI keys and leave it off agent keys. Keys created before this rule that relied on full access to call `POST /inbound/raw` now get `403`; create a key with `inbound_raw` for that.

`permissions` is sent as an object of booleans (`{"message_read": true, "message_send": true}`) or as an array of names. Responses always use the object form. An unknown name returns `400 Unknown permission: <name>`.

| Permission                   | Grants                                                                    |
|------------------------------|---------------------------------------------------------------------------|
| `inbox_read`                 | `GET` inboxes                                                            |
| `inbox_create`               | `POST /v0/inboxes`                                                       |
| `inbox_update`               | `PATCH /v0/inboxes/{inbox_id}`                                         |
| `inbox_delete`               | `DELETE /v0/inboxes/{inbox_id}`                                         |
| `thread_read`                | `GET` threads                                                            |
| `thread_delete`              | `DELETE` threads                                                         |
| `message_read`               | `GET` messages, attachments, raw messages, trust reports, export; `POST .../messages/batch-get` |
| `message_send`               | `POST .../messages/send`, `.../forward`, `.../reply`, `.../reply-all`  |
| `message_reply`              | `POST .../reply` and `.../reply-all` (either this or `message_send`)  |
| `message_update`             | `PATCH` messages and threads; `POST .../messages/batch-update`         |
| `message_delete`             | `DELETE` messages                                                        |
| `label_spam_read`           | see `SPAM` mail when you ask for it (`include_spam` or `labels`)      |
| `label_blocked_read`        | see `BLOCKED` mail when you ask for it                                  |
| `label_unauthenticated_read` | see `UNAUTHENTICATED` mail when you ask for it                          |
| `label_trash_read`          | see `TRASH` mail when you ask for it                                    |
| `draft_read`                | `GET` drafts                                                            |
| `draft_create`              | `POST .../drafts`                                                       |
| `draft_update`              | `PATCH .../drafts/{draft_id}`                                          |
| `draft_delete`              | `DELETE .../drafts/{draft_id}`                                         |
| `draft_send`                | `POST .../drafts/{draft_id}/send`                                      |
| `webhook_read`              | `GET` webhooks and deliveries                                           |
| `webhook_create`            | `POST /v0/webhooks`                                                    |
| `webhook_update`            | `PATCH` webhooks; `POST .../test`; `POST .../deliveries/{delivery_id}/replay` |
| `webhook_delete`            | `DELETE` webhooks                                                       |
| `domain_read`               | `GET` domains, zone files, Domain Connect                                |
| `domain_create`             | `POST /v0/domains`                                                     |
| `domain_update`             | `PATCH` domains; `POST .../verify`                                     |
| `domain_delete`             | `DELETE` domains                                                         |
| `list_entry_read`          | `GET` allow/block lists and suppressions                                |
| `list_entry_create`         | `POST` list entries and suppressions                                    |
| `list_entry_delete`         | `DELETE` list entries and suppressions                                  |
| `metrics_read`              | `GET` metrics, the event log, `GET /v0/events/stream`                 |
| `api_key_read`              | `GET` API keys; `GET` agent registrations                               |
| `api_key_create`            | `POST` API keys                                                         |
| `api_key_update`            | `PATCH` API keys                                                         |
| `api_key_delete`            | `DELETE` API keys; `DELETE` agent registrations                         |
| `pod_read`                  | `GET` pods                                                              |
| `pod_create`                | `POST /v0/pods`                                                        |
| `pod_update`                | `PATCH /v0/pods/{pod_id}`                                             |
| `pod_delete`                | `DELETE /v0/pods/{pod_id}`                                             |
| `inbound_raw`               | `POST /inbound/raw` (inject a raw RFC 5322 message; MachineMail only). Explicit grant only |

`GET /v0/organizations` needs no permission. The `/v0/ws` WebSocket needs any of `message_read`, `inbox_read`, `domain_read`, `draft_read` or `webhook_read`. Both event streams then send each event only when the credential holds its read permission: `message.*` needs `message_read`, `inbox.*` `inbox_read`, `domain.*` `domain_read`, `draft.*` `draft_read`, `webhook.test` `webhook_read`.

The label permissions never cause a `403`. Without them, asking for spam, blocked, unauthenticated or trash mail is silently ignored and that mail stays hidden. `QUARANTINE` mail needs no extra permission.

Each endpoint page in the [API reference](/content/docs/reference/api/index.html) lists the permission it needs. [MCP tools](/content/docs/reference/mcp-tools/index.html) use the same permissions as the matching `/v0` route.

## Agent access tokens

Agents can onboard themselves without a human creating a key first, using the auth.md protocol. The full flow is described at `GET /auth.md` and on the [Agent Auth](/content/docs/reference/api/agent-auth/index.html) page. In short:

1. `POST /agent/identity` registers the agent. An `anonymous` registration gets a sandbox inbox on the shared domain and an `identity_assertion`.
2. `POST /oauth2/token` with `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` exchanges the assertion for an access token (a JWT, valid for 1 hour).
3. `POST /agent/identity/claim` starts a claim: a person confirms a 6-digit code in the dashboard, which binds the agent to their organization.

Access tokens go in the same `Authorization: Bearer` header as API keys. Their OAuth scopes map to permissions:

| Scope                | Permissions                                                           |
|----------------------|----------------------------------------------------------------------|
| `inboxes.read`       | `inbox_read`                                                         |
| `inboxes.write`      | `inbox_create`, `inbox_update`, `inbox_delete`                    |
| `messages.read`      | `message_read`, `thread_read`, `label_spam_read`, `label_blocked_read`, `label_unauthenticated_read`, `label_trash_read` |
| `messages.send`      | `message_send`, `message_reply`, `draft_read`, `draft_create`, `draft_update`, `draft_delete`, `draft_send` |
| `messages.write`     | `message_update`, `message_delete`, `thread_delete`                |
| `webhooks.read`      | `webhook_read`                                                      |
| `webhooks.write`     | `webhook_create`, `webhook_update`, `webhook_delete`              |
| `domains.read`       | `domain_read`                                                       |
| `domains.write`      | `domain_create`, `domain_update`, `domain_delete`                  |
| `lists.read`         | `list_entry_read`                                                  |
| `lists.write`        | `list_entry_create`, `list_entry_delete`                           |
| `metrics.read`       | `metrics_read`                                                      |
| `pods.read`          | `pod_read`                                                          |
| `pods.write`         | `pod_create`, `pod_update`, `pod_delete`                           |
| `api_keys.read`      | `api_key_read`                                                     |
| `api_keys.write`     | `api_key_create`, `api_key_update`, `api_key_delete`             |

No scope grants `inbound_raw`.

| Registration state              | Token scope                                    | Scopes                            |
|--------------------------------|------------------------------------------------|-----------------------------------|
| Unclaimed `anonymous`          | its one sandbox inbox                           | `inboxes.read`, `messages.read`, `messages.send`, `messages.write` |
| Unclaimed `service_auth` or `identity_assertion` | none: the token is rejected with `401` |                                   |
| Claimed                         | the claiming organization                       | every scope                       |

An unclaimed sandbox inbox can send 10 messages to 10 different recipients per rolling 24 hours, with at most 5 recipients per message (see [Rate limits](/content/docs/reference/rate-limits#sandbox-sends/index.html)). Claiming or revoking a registration invalidates the tokens issued before it.

Token lifetimes:

| Item                         | Lifetime             |
|------------------------------|----------------------|
| Access token                 | 1 hour               |
| Identity assertion           | 24 hours, never past the registration’s claim window |
| Claim window for an unclaimed registration | 24 hours  |
| Sandbox inbox                | 24 hours              |
| Claim user code             | 10 minutes            |

## Dashboard session

`mm_session` is a cookie: `HttpOnly`, `SameSite=Lax`, `Secure` on HTTPS, path `/`, valid for 30 days. A session acts as the person’s current organization with no permission list. It is accepted on `/v0/*` and the `/v0/ws` upgrade (but cannot call `POST /inbound/raw`, which needs a key with `inbound_raw`), and required by `/auth/me`, `/auth/first-key`, `/auth/logout` and `/auth/claim`. `/mcp` never accepts it.

A session is bound to one organization and works only while the person is a member of it.

### Signing in

People sign in on MachineMail’s own pages, `/login` and `/signup`, with a one-time code sent by email, a passkey, a social provider or single sign-on. Signing up is the same as signing in: the account is created on the first sign-in.

The ways to sign in:

- **Email code.** The person enters their address on `/login` or `/signup`. `POST /auth/email/start` sends a sign-in code from `sign-in@machinemail.dev`. The person types the 6-digit code into one field, and `POST /auth/email/verify` checks it. A code lasts 10 minutes and works once. Sending again sends a new code.
- **Passkey.** On `/login`, the **Passkey** button, or pick a passkey from the email field’s suggestions. Once the person has added one; see [Passkeys](/content/docs/reference/authentication#passkeys/index.html).
- **Social providers.** Each button goes straight to the provider (`GET /auth/social/{provider}`), with no page in between. `GET /auth/config` lists the providers on offer, from `google`, `github`, `apple` and `microsoft`. The provider returns the browser to `GET /auth/callback`.
- **SSO.** After the email step, if the address’s domain is verified by an organization with an active SSO connection, the browser goes to that connection instead of getting a code. If an organization requires SSO, other methods are refused for its members, and MachineMail sends them on to the connection too.

Every redirect carries a single-use `state`, tied to the browser by a short-lived `mm_oauth_state` cookie (`HttpOnly`, path `/auth/callback`). `next` is kept through the round trip, and it is only ever a path on MachineMail.

Sometimes a sign-in needs one more step. MachineMail holds that step on the server, behind an `HttpOnly``mm_signin` cookie (path `/auth`, 10 minutes), and shows its own page:

- `/login/organization`: the person belongs to several organizations, and has to pick the one to sign in to. Only an organization offered for that sign-in is accepted.
- `/login/verify`: the provider did not vouch for the email (GitHub can do this). MachineMail emails a verification code to the address, and the person enters it.

`GET /auth/pending` returns the step in progress, for these pages.

### Passkeys

A passkey signs a person in with their fingerprint, face or device PIN instead of an emailed code. Their password manager or device keeps it, and it only works on MachineMail. Where passkeys are on offer, `GET /auth/config` has `"passkey": true`, and `/login` shows a **Passkey** button next to the social sign-in buttons. Browsers that support passkey autofill also list the person’s passkeys in the email field’s suggestions.

- **Adding one.** In the dashboard, **Settings → Security → Add a passkey** (`POST /auth/passkeys/registration`, then `POST /auth/passkeys` with what the browser created). A passkey belongs to the person, not to an organization. Each one needs user verification (a fingerprint, face or PIN), and an account can have 20. Passkeys can be renamed there, and show when they were last used and whether the password manager syncs them.
- **Signing in.**`POST /auth/passkey/options` returns the options for the browser’s WebAuthn API and sets an `mm_passkey` cookie (`HttpOnly`, path `/auth`) holding a challenge that works once, for 5 minutes. `POST /auth/passkey/verify` checks the browser’s answer and replies like `POST /auth/email/verify`: signed in, or the next step (picking an organization, single sign-on). Organizations’ sign-in policies apply as they do to an email code, so one that requires single sign-on still sends its members there. Passkey sign-ins are limited to 4 a minute per address, apart from email codes: asking for codes can’t hold up a passkey, and signing in with a passkey never uses up code tries.
- **A passkey removed from the account** answers `400` with `error.code``passkey_unknown`. The dashboard asks the browser to forget it, where the browser supports that, and says to delete it from the password manager. Any other passkey that doesn’t check out is `400` with `error.code``passkey_invalid`.
- **Confirming it’s you.** Adding or removing a passkey and turning email codes off need a sign-in within the last 10 minutes. Otherwise the API answers `403` with `error.code``step_up_required` and `error.methods`, and the dashboard asks the person to confirm with a passkey or with a 6-digit code emailed to them (10 minutes, 3 tries, 3 codes an hour). Confirming counts as a fresh sign-in for 10 minutes.
- **Turning email codes off.** With at least one passkey, a person can turn email sign-in codes off (`PUT /auth/email-codes`). A code asked for their address is then not sent: the address gets an email saying to sign in with a passkey instead, and `/login` looks the same either way. A code sent before codes were turned off stops working. While codes are off, the last passkey can’t be removed (`409`): turn codes back on or add another passkey first. Social sign-in and single sign-on keep working.
- **Removing a passkey** signs out every session it signed in, on every device, this one too if it signed in here. **Sign out other sessions** (`POST /auth/sessions/revoke-others`) ends all of the person’s sessions except the current one.
- **Emails.** The address gets an email whenever a passkey is added or removed and whenever email codes are turned off or back on, with a link to **Settings → Security**.
- **Lost every passkey with email codes off?** Contact [support@machinemail.dev](mailto:support@machinemail.dev). Once we’ve checked it’s you, we can turn email codes back on or remove a lost passkey.

Right after a sign-in, the dashboard may offer to create a passkey. **Not now** asks again in 14 days; **Don’t ask again** stops asking.

### Invitations

An invitation email links to `https://machinemail.dev/invite/{token}`. The page shows the organization and the invited address (masked), from `GET /auth/invitations/{token}`. An unknown token is `404` and the page says the link isn’t valid. When the lookup is rate limited or cannot be answered right now, the API answers `429` with `Retry-After` (never a `500`), and the page says it couldn’t load the invitation, with a **Try again** button. The person then signs in with an email code, which goes to the invited address, or with a social provider. Either way, the invitation token goes with the sign-in, so signing in accepts the invitation. A link of the form `/invite?invitation_token=...` works too.

### First sign-in and errors

On a person’s first sign-in:

- If a MachineMail account with the same email already exists and the email is verified, the sign-in is linked to it. An email code verifies the address. The account keeps its organizations and everything in them.
- If the email matches an existing account but is not verified, sign-in is refused with `/login?error=unverified`.
- A person with no organization gets one, with a Default Pod and a first API key. The key is shown once, from `GET /auth/first-key`.
- A new account needs an email address outside MachineMail. An address that receives mail through MachineMail (an inbox on a MachineMail domain, or on a custom domain verified on MachineMail) can’t sign up. The code (or sign-in link) is sent to it as to any other address; once it is entered, `POST /auth/email/verify` answers `403` with `error.code``signup_address_not_allowed` instead of creating the account, and a social or single sign-on sign-in ends at `/login?error=address_not_allowed`. People who already have an account sign in as before. Invitations to an address on a MachineMail domain are refused when they are sent, whether or not the address has an account. An organization that verified a custom domain can still invite people with addresses on it, and its single sign-on can create their accounts. Those people belong to the organizations that invite them but can’t create organizations of their own.
- MachineMail doesn’t accept disposable email addresses (throwaway inboxes) for new accounts. Like the rule above, the refusal comes once the code is entered (`POST /auth/email/verify` answers `403` with `error.code``disposable_email_not_allowed`) or the link is opened, and a social or single sign-on sign-in ends at `/login?error=disposable_address`. Privacy relays and alias services, which forward to a real mailbox, are accepted. People who already have an account sign in as before, and invitations and agent claims are not refused for this.
- One account per mailbox. Gmail addresses with the dots moved (or on `googlemail.com`) and `+` aliases on any domain (`you+test@example.com`) are the same mailbox as `you@example.com`. A new account for a mailbox that already has one is refused once the code is entered (`POST /auth/email/verify` answers `403` with `error.code``mailbox_has_account`) or the link is opened, and a social or single sign-on sign-in ends at `/login?error=mailbox_has_account`. The message names the existing account’s address, masked (`k•••@gmail.com`), to sign in with. An invitation is for the address it was sent to, so when the invited address is another spelling of a mailbox that has an account, ask the organization to invite the existing account’s address instead. Before that, every address gets the same answer. Accounts already on such an address sign in as before, and limits per email address count per mailbox.

Failures end on `/login?error=` with one of these codes:

| Code                  | Meaning                                                         |
|-----------------------|-----------------------------------------------------------------|
| `signin_failed`       | The sign-in was refused, or it failed.                          |
| `access_denied`       | The person cancelled at the provider.                            |
| `unverified`          | The email is not verified and belongs to an existing account.    |
| `linked`              | The account is already linked to a different sign-in identity.   |
| `mfa`                 | The sign-in asked for multi-factor authentication. MachineMail does not support this yet. |
| `methods`             | The person’s organization does not allow this sign-in method.   |
| `codes_paused`        | Too many codes for the address recently, so no verification code was sent. Try later. |
| `address_not_allowed`  | A new account for an address that receives mail through MachineMail. Sign up with an address outside MachineMail. |
| `disposable_address`   | A new account for a disposable email address. Sign up with an address you’ll keep receiving mail at. |
| `mailbox_has_account`  | A new account for a mailbox that already has one. Sign in with the existing account’s address, given masked in `account`. `invited=1`: an organization brought the person in; ask it to invite that address instead. |

A wrong code is `400` on the code field. After 3 wrong codes for one code, the sign-in is gone (`404`) and the person sends a new code. A code that has expired or was already used also ends the sign-in with a `404` (“send a new code”), and so does an invitation that was revoked, expired or accepted elsewhere after the code was sent (“This invitation is no longer valid”). Sending a new code cancels the earlier one: only the code in the newest email works (an earlier one is `400`), from whichever browser asked for it. At most 4 right codes per address a minute are accepted; past that, and when sign-in for an address is briefly paused, the answer is `429` (“Too many sign-in attempts. Try again in N minutes.”) with `Retry-After`, and the sign-in is kept so the same code works afterwards. Wrong codes never count toward this, so guessing cannot keep the person out.

Wrong codes are also counted per email address and client IP (an IPv6 client by its `/64`), across every code: after 20 wrong codes for an address from one IP in 24 hours, that IP gets no new code and no code check for the address (`429`, with `Retry-After`) until the oldest wrong code is a day old, and the address gets an email saying so. Someone guessing pauses only their own network: the person’s right code from anywhere else still works. New codes (email codes and email verification codes together) are limited: 5 per 15 minutes per address and client IP, and 10 per rolling hour per address across every IP. That bounds guessing at 3 tries per code, so 30 an hour per address, however many IPs are used. These only delay new codes, never the check of a code already sent, and wrong codes alone do not hold back new codes for other networks; a verification that cannot get a code ends at `/login?error=codes_paused`. `POST /auth/email/start` answers the same way whether or not the address has an account, including for addresses that receive mail through MachineMail. It only says something different for a domain that signs in with SSO. Codes and checks are rate limited per IP address and per email address; see [Rate limits](/content/docs/reference/rate-limits#dashboard-sign-in/index.html). The sign-in `POST` endpoints refuse requests whose `Origin` is another site.

`POST /auth/logout` ends the MachineMail session.

### Sign-in methods

MachineMail does not offer magic-link sign-in: `POST /auth/magic-link` returns `404`. Sign in with an email code instead.

`GET /auth/config` is public. It tells a client which sign-in methods to show:

```json
{ "workos": true, "email_code": true, "providers": ["google", "github"], "magic_link": false, "password": false, "authkit": false, "passkey": true }
```

`email_code`, `providers` and `passkey` say which methods to offer; `magic_link` says whether `POST /auth/magic-link` is on. The `workos` and `authkit` fields are kept for older clients: `workos` is `true` exactly when `email_code` is, and `authkit` is deprecated and always `false`. `GET /auth/authkit` is a deprecated path that redirects to `/login`.

### Switching organizations

One person can belong to several organizations. The dashboard session works on one at a time:

| Method              | Path                                 | Does                                      |
|---------------------|--------------------------------------|-------------------------------------------|
| `GET`               | `/auth/organizations`                | Lists the person’s organizations, each with their `role` and a `current` flag |
| `POST`              | `/auth/organizations/switch`        | Moves the session to `{ "organization_id": "..." }` |
| `POST`              | `/auth/organizations`               | Creates an organization from `{ "name": "..." }`, as its owner, and switches to it |

A session belongs to the organization it signed in to. Switching to another organization checks the session against that organization’s own sign-in policy. When it does not allow how the person signed in, for example it requires single sign-on and they used an email code, the switch is refused with `403` and the person signs in again. The same check picks where a sign-in lands: never in an organization whose policy the sign-in does not meet. Leaving an organization (`DELETE /v0/organizations/members/{your user_id}`) moves the session the same way: to the first organization you belong to whose policy the session meets. When none does, the session ends, the `mm_session` cookie is cleared, and the dashboard sends you to `/login`.
