# Authentication

Every request to the ReplyLayer API carries one of two credentials, and both
resolve to the same account context:

- **API key** — `Authorization: Bearer <api_key>`. This is what agents, the CLI,
  and any programmatic integration use.
- **Session cookie** — the `rl_session` HTTP-only cookie the dashboard sets when a
  human signs in at [app.replylayer.ai](https://app.replylayer.ai).

Every endpoint except the auth and health routes requires one of them. If a
request carries **both**, the API authenticates it as the **Bearer key** — see
[Bearer wins when both are present](#bearer-wins-when-both-are-present), the one
footgun that trips people up.

For the machine-readable contract — exact request/response shapes and the auth
error taxonomy — see the [agent-facing auth reference](/agents/auth).

## Key format

A ReplyLayer key looks like this:

```text
rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z
```

It is two dot-separated parts — a **public id** (`rly_live_k3m9p2qx7vn4hjd0`) and a
**secret**. The public id is what the API stores and what shows up (masked) in the
key list; the secret is never stored in plaintext and is shown to you **exactly
once**, in the response body of the call that mints the key. If you lose it before
saving it, you cannot recover it — mint a replacement.

Send the whole string, unmodified, in the `Authorization: Bearer` header.

## Roles: admin vs agent

Every API key has one of two roles, chosen at creation. There is no implicit
default — you must pass `role` when you create a key.

| | **admin** | **agent** |
|---|---|---|
| Scope | Full account access — same authority as a dashboard session | Bound to one or more specific mailboxes only |
| `mailbox_ids` at creation | Forbidden (must be empty/absent) | **Required, non-empty** |
| Send / receive / read / reply / quarantine | All mailboxes | Bound mailboxes only |
| Manage mailboxes, keys, scanner policy, account | Yes | No — `403 INSUFFICIENT_SCOPE` |
| Typical holder | Your control-plane / provisioning code | A single deployed agent |

An **agent** key that touches a mailbox it isn't bound to gets `403
MAILBOX_ACCESS_DENIED` on writes; read masking is deliberately asymmetric so a key
can't probe for mailboxes it can't see — a single-resource detail read of an
unbound mailbox or message returns `404 NOT_FOUND`, while a mailbox-scoped
collection read of an unbound mailbox UUID returns `403`. `GET /v1/mailboxes`
returns only the bound mailboxes for an agent key.

**Give each deployed agent its own agent-role key, scoped to only the mailboxes it
needs.** That way a leaked credential is contained to those mailboxes, and you can
revoke it without disturbing anything else.

## Bearer wins when both are present

If a request carries **both** an `rl_session` cookie **and** an `Authorization:
Bearer` header, the API uses the **Bearer key** and ignores the session. This
matters because some flows behave differently depending on which credential
authenticated the call.

The classic way to hit this by accident: you're signed into the dashboard, you copy
a `curl` command out of browser DevTools, and that copy carries an `Authorization`
header from an old key you had lying around. The request runs as that Bearer key,
not as your session — silently.

Concretely, rotation is the sharpest edge (see [Rotating keys](#rotating-keys)):
the session-triggered rotation revokes *all* admin keys, but a Bearer-triggered
rotation revokes only the calling key. Same endpoint, different blast radius,
decided entirely by which credential authenticated.

**To force the session flow:** use the dashboard UI, or run your `curl` from a
clean shell with no `Authorization` header set.

## Surfaces that require a dashboard session

Most of the API is fully agent-drivable with a Bearer key. A small set of
**sensitive, human-in-the-loop toggles** are deliberately session-only — a Bearer
key (admin *or* agent) is rejected before any state change. These are enablement
gates, not everyday agent operations:

| Surface | Contract |
|---|---|
| Enabling approved raw attachment downloads for a mailbox | Dashboard session **plus fresh re-auth** (TOTP or password). A Bearer key → `403 REAUTH_REQUIRES_SESSION`. See the [attachments reference](/agents/attachments). |
| Enabling outbound attachments for a mailbox | Dashboard session **plus fresh re-auth**. A Bearer key → `403 REAUTH_REQUIRES_SESSION`. |
| **Loosening** instruction trust (granting a trusted sender, enabling trusted-instruction mode, turning a strict-recipient guard off) | Dashboard session **plus fresh re-auth**. Bearer keys — including admin — are rejected. An agent can never enable, grant, or loosen instruction trust for itself. See the [security-model reference](/agents/security-model). |
| Enabling TOTP / MFA | Session, admin-only. |
| MFA management ceremonies — enrolling SMS-based MFA, switching your MFA method, the SMS disable proof, the lost-factor recovery flows, and changing your phone number | Dashboard session only. A Bearer key (admin *or* agent) → `403 REAUTH_REQUIRES_SESSION`. |
| Issuing a CLI signup code | Session-only — a Bearer key → `403 SESSION_AUTH_REQUIRED`. |
| Changing your account password | Dashboard session **plus fresh re-auth**. A Bearer key → `403 REAUTH_REQUIRES_SESSION`. |
| Changing your account email | Dashboard session **plus fresh re-auth**. A Bearer key → `403 REAUTH_REQUIRES_SESSION`. |

**Tightening** the same controls is not gated this way. Revoking a trusted sender,
disabling trusted-instruction mode, or turning a strict-recipient guard *on* is
unprivileged and agent-callable, so an incident-response agent can clamp down fast.
The asymmetry is intentional: loosening needs a human, tightening does not.

The "fresh re-auth" these gates require is method-aware. A TOTP account passes a
current authenticator code as `reauth_token`; a password-only account passes its
password; an **SMS-based MFA** account first mints a scoped SMS re-auth challenge
(`POST /v1/auth/reauth/sms/send`, one `action_scope` per action) and then passes
`reauth_challenge` plus the SMS code. The challenge is bound to your session, the
specific action, and the exact values you're changing, so it cannot be replayed
against a different operation.

Text-message codes are a weaker factor than an authenticator app — phone numbers
can be hijacked by SIM-swap attacks. The dashboard surfaces this tradeoff wherever
you choose SMS-based MFA or switch to it from an authenticator app.
## Recovering from a lost phone (two-factor)

If you enrolled two-factor with a text-message (SMS) number and lose access to
that phone, sign in with one of the recovery codes you saved when you turned
two-factor on — either the dedicated recovery-code login (email + password +
recovery code) or the recovery-code field on the ordinary two-factor prompt. A
successful recovery-code sign-in carries a one-time, time-limited grant and lands
you straight on **Settings → Fix your two-factor**, where you can:

- **Move two-factor to a new phone** (SMS accounts only) — enter the new number,
  verify a code texted to it, and finish. Fresh recovery codes are issued as part
  of this step, and your other sessions are signed out for safety.
- **Turn off two-factor entirely** (either method) — deletes your recovery codes
  and signs out your other sessions. Set two-factor up again from Settings
  whenever you're ready.

If you navigate away before finishing, a banner keeps reminding you and links back
to the same page until the recovery window closes. The window is deliberately
one-shot and short-lived — recovering doesn't leave a standing backdoor — but
refreshing mid-flow, or restarting the phone move from another tab, safely
restarts the same ceremony instead of locking you out of it.

This is a dashboard-only, human-in-the-loop flow: there's no way to script it with
an API key, because fixing your own two-factor is deliberately not an agent
operation.

## Adding a phone number without a password (SSO accounts)

Accounts created before mandatory phone verification, or that sign in only via
Google or GitHub with no password and no two-factor enrolled, may have no phone
number on file at all. Settings offers an **"Add phone number"** action for
exactly these accounts: since there's no password or enrolled two-factor to prove
you're the owner, we first email a one-time code to your account address —
confirm it, then verify the new number the same way any phone change works (a
text to the new number). This applies only while the account genuinely has no
password, no two-factor, and no phone bound yet; the moment any of those exist,
adding or changing a phone number goes through the ordinary current-factor flow
instead.

## Changing your password

While signed in, Settings offers a **"Change password"** action — no need to
sign out and drive the forgot-password flow. It requires a fresh factor proof
(the same TOTP/SMS/password step-up described above), and on success it signs
out every other session; the browser you completed the change in stays signed
in with a rotated session. This is a dashboard-only, human-in-the-loop flow —
there's no way to script it with an API key.

## Changing your email

Settings also offers a **"Change email"** action to update the address you sign
in with. After the same fresh factor proof, we email a 6-digit code to the
**new** address — confirming it swaps your sign-in email and, like a password
change, signs out every other session. You can only change your email once
every 24 hours. If the address you enter is already in use on another account,
you won't find out until you've proven you control it (entered the code) — this
avoids leaking which addresses have accounts. Dashboard-only; not scriptable
with an API key.

## Creating a key

Only an **admin** key (or a dashboard session) can mint keys. The secret comes back
once, in this response — store it before you do anything else.

**Agent key:**

```bash
curl -X POST https://api.replylayer.ai/v1/accounts/api-keys \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "role": "agent",
    "label": "support-bot",
    "mailbox_ids": ["<mailbox-uuid-1>", "<mailbox-uuid-2>"]
  }'

# 201 Created
# {
#   "id": "<key-uuid>",
#   "api_key": "rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z",
#   "role": "agent",
#   "label": "support-bot",
#   "mailbox_ids": ["<mailbox-uuid-1>", "<mailbox-uuid-2>"]
# }
```

**Admin key:** same call with `"role": "admin"` and no `mailbox_ids`.

Rules the create endpoint enforces:

- `role` is required — no default.
- An `agent` key needs a non-empty `mailbox_ids`; an `admin` key must omit it.
  Getting this wrong returns `422 VALIDATION_ERROR`.
- Every mailbox id must belong to your account and be active.
- **Maximum 10 active keys per account.** At the cap, the create call returns `403
  FORBIDDEN` — revoke a stale key first.

> A freshly signed-up account's first key and session are **locked** on protected
> routes until both signup factors complete. The API returns `403
> EMAIL_NOT_VERIFIED` while email is pending, then `403 PHONE_NOT_VERIFIED` while
> the required SMS check is pending. The key/session can still call the
> verification and resend endpoints below.

## Completing signup verification

All new password, API/CLI, Google, and GitHub accounts provide a mobile number at
signup. Existing Google/GitHub login and identity-link flows are unaffected. The
dashboard guides password signups through email first and SMS second; API callers
may complete the two authenticated checks in either order, but protected product
access and mailbox/subdomain provisioning wait for both.

Verify the email code:

```bash
curl -X POST https://api.replylayer.ai/v1/auth/verify-email \
  -H "Authorization: Bearer $NEW_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code":"482917"}'
```

Verify the 6-digit SMS code (valid for 5 minutes):

```bash
curl -X POST https://api.replylayer.ai/v1/auth/verify-phone \
  -H "Authorization: Bearer $NEW_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code":"593104"}'
```

Only the latest SMS challenge is accepted. To resend, post `{}` to
`/v1/auth/resend-phone-verification`. Before verification you may correct a typo
and resend atomically by posting `{ "phone_number": "+13125550124" }`; after
verification this bootstrap route cannot replace the number. A successful resend
returns `phone_number_masked` plus `expires_at`, never the full number.

SMS verification is available only for numbers in supported countries; others
get a terminal `400 PHONE_COUNTRY_NOT_SUPPORTED` whose message lists the
currently supported markets.

SMS sends have a 30-second cooldown and bounded per-phone, per-account, and per-IP
windows. On `429 PHONE_VERIFICATION_RATE_LIMITED`, honor `Retry-After` /
`details.retry_after`. If the initial signup response reported
`sms_delivery_status: "pending"`, the account exists and this authenticated resend
is the recovery path.

`GET /v1/auth/me` exposes `email_verified`, `phone_verification_required`,
`phone_verified`, and `phone_number_masked`, so a client can render the next step
without handling the full phone number.

## Verifying a key

After minting or deploying a key, confirm it authenticates. Use `GET /v1/auth/me`
(a single, cheap authenticated read) or any account-scoped read such as `GET
/v1/mailboxes`:

```bash
curl https://api.replylayer.ai/v1/auth/me \
  -H "Authorization: Bearer $NEW_KEY"

# 200 OK — returns account info for the authenticated caller
```

> **Do not use `GET /v1/health` to validate a key.** Health is a liveness probe in
> the auth-exempt route set — it returns `200 ok` regardless of whether the Bearer
> token is valid, missing, or revoked. A `200` from `/health` tells you the service
> is up, nothing about your credential. `rly doctor` and `rly auth status` (see
> [/agents/cli](/agents/cli)) wrap the `/v1/auth/me` check for you.

## Rotating keys

Rotate by rolling a fresh key in **before** you retire the old one, so a
long-running integration never hits an auth gap. The pattern is
**create → verify → revoke**:

1. **Create** a new key (`POST /v1/accounts/api-keys`) — this mints a key without
   touching any existing one. Both keys are now valid simultaneously.
2. **Deploy** the new key to your secret store and restart (or let your code
   re-read) any process holding the old secret.
3. **Verify** the new key authenticates with `GET /v1/auth/me`.
4. **Revoke** the old key explicitly:

```bash
curl -X DELETE "https://api.replylayer.ai/v1/accounts/api-keys/$OLD_KEY_ID" \
  -H "Authorization: Bearer $NEW_KEY"

# 200 OK  { "status": "revoked" }
```

Find `$OLD_KEY_ID` — the `id` field — via `GET /v1/accounts/api-keys`. A DELETE on a
non-existent or already-revoked key returns `404 NOT_FOUND` (and writes no audit
row).

### The all-in-one rotate endpoint

`POST /v1/accounts/api-keys/rotate` mints a new admin key and revokes old ones in a
single transaction. Because it revokes inside the same transaction that mints, any
in-flight request from another process using the old key fails `401` the moment the
call returns — so this is the **destructive** path, best for a personal CLI session,
a one-off script, or compromise response. For a running integration, prefer the
create-verify-revoke steps above.

Its blast radius depends on how you authenticated (see [Bearer wins when both are
present](#bearer-wins-when-both-are-present)):

- **Bearer-triggered** — revokes only the calling key, mints one new admin key.
- **Session-triggered** (dashboard) — revokes **all** admin keys, mints one new
  admin key. Use this to burn every admin credential at once during a suspected
  compromise.

Either way, **agent keys are never touched by rotate**, and the endpoint only ever
mints an **admin** key. To roll an agent key, use create-verify-revoke: mint a new
agent key with the *same* `mailbox_ids`, switch the agent over, verify, and delete
the old one.

> Per-key capabilities do **not** carry to a rotated or replacement key. A new key
> starts with the default (least-privileged) settings; any capability the old key
> had must be re-enabled explicitly on the new one.

## Revoking a key

`DELETE /v1/accounts/api-keys/:id` revokes a single key. It is admin-only, with one
deliberate exception: **any key may revoke itself** by passing its own `id`. That
lets a compromised or retiring agent burn its own credential without an admin in the
loop — a strictly capability-reducing action. An agent key trying to revoke a
*different* key gets `403 INSUFFICIENT_SCOPE`. Revocation is not cached: the revoked
key fails auth (`401`) on its very next request.

To find keys and their state, `GET /v1/accounts/api-keys` lists active keys by
default; pass `?include_revoked=true` to include revoked keys with their
`revoked_at` / `revoked_by` metadata. The list returns each key's `id`, masked
`prefix`, `status`, `role`, `label`, `mailbox_ids`, and timestamps — never the full
secret.

**No email or webhook fires when a key is created or revoked.** If your security
policy needs alerting on key changes, poll `GET /v1/accounts/api-keys` and diff.

## Auth errors

The auth-flow codes you'll encounter:

| HTTP | Code | Meaning |
|---|---|---|
| 401 | `UNAUTHORIZED` | Missing, invalid, or revoked credential — re-authenticate. |
| 403 | `EMAIL_NOT_VERIFIED` | Account not yet email-verified — complete verification. |
| 403 | `PHONE_NOT_VERIFIED` | Required signup phone is not yet verified — complete the SMS check. |
| 403 | `INSUFFICIENT_SCOPE` | Admin-only action attempted with an agent key. |
| 403 | `MAILBOX_ACCESS_DENIED` | Agent key touched an unbound mailbox. |
| 403 | `REAUTH_REQUIRES_SESSION` | A session-only enablement gate was hit with a Bearer key. |
| 403 | `SESSION_AUTH_REQUIRED` | A session-only route was hit with a Bearer key. |
| 403 | `FORBIDDEN` | At the 10-key cap on create. |
| 404 | `NOT_FOUND` | DELETE on a missing/already-revoked key, or a masked unbound resource. |
| 422 | `VALIDATION_ERROR` | Role/`mailbox_ids` mismatch on create. |

For the complete cross-surface error catalog with recovery guidance, see the
[agent error reference](/agents/errors).

## See also

- The [agent-facing auth reference](/agents/auth) — the machine contract: exact request/response
  shapes and per-code failure modes.
- [/agents/cli](/agents/cli) — `rly auth` / `rly doctor` and how the CLI stores
  credentials.
- [/security](/security) — how credentials and content are protected at rest and in
  transit.
- [github.com/replylayer/rly](https://github.com/replylayer/rly) — the open CLI
  and SDK source.

Lost every admin key and your dashboard session? Operator recovery is intentionally
bounded to verified account ownership and is slow by design — email
[support@replylayer.ai](mailto:support@replylayer.ai) with your account email and id
rather than relying on it as a routine path.
