---
title: "Authentication"
description: "Sign in with your browser or an API key, keep several profiles, and verify a code before a sensitive change."
url: "https://openemail.uk/docs/cli/authentication"
area: "CLI"
category: "Getting started"
---

# Authentication

Sign in with your browser or an API key, keep several profiles, and verify a code before a sensitive change.

## Two ways to sign in

Run `openemail login` in a terminal and it asks which you want. Either way the sign-in is saved as a profile, and every later command uses the active one.

| Command | Acts as | Verification codes |
| --- | --- | --- |
| openemail login | You, in the workspace and with the access you approve | Asked before a few sensitive changes |
| openemail login --with-token | The workspace, with the scopes the key holds | Never asked |

- Only a browser sign-in can use `ai compose`, `ai summarize` and the MCP commands.
- A browser sign-in lasts until the approval you chose expires, or until you sign out. A key works until it is revoked.

## Browser sign-in

1. `openemail login` registers a new app for this sign-in, named `OpenEmail CLI on <your computer>`, and opens the OpenEmail approval page in your browser. If the browser does not open, use the link it prints.
2. Sign in if you need to, then choose the workspace, the access the CLI gets (read, read and send, full, or your own set of permissions), the domains or addresses it reaches, and how long the approval lasts.
3. Approve. The browser hands the approval back to the terminal on its own, and you can close the tab. The CLI prints who you are signed in as, the workspace, and when the approval expires.

**Terminal**

```
openemail login
openemail login --scopes emails:send,threads:read
openemail login --profile work
```

- The CLI waits 10 minutes for you to approve. Choosing Not now on the approval page cancels the sign-in, with exit code `10`.
- `--scopes` preselects permissions on the approval page, and you can still change them there.
- When the profile already holds a sign-in, a terminal asks before replacing it. Unattended it refuses, unless you pass `--force` or `--yes`. Replacing a browser sign-in revokes the old one.

> Each browser sign-in is its own connected app, listed in Account → Connected apps with the access you approved, where you can change or remove it. `openemail open apps` opens that page.

Underneath is the OAuth flow the MCP server uses: a public client with PKCE, a one-time code, and an access token that lasts an hour and is refreshed for you. The browser returns to `127.0.0.1` on a random port, and only the code of this sign-in is accepted there.

## Over SSH, or without a browser

When the CLI cannot open a browser on this machine, it prints the link instead: over SSH, in CI, on Linux without a display, or when you pass `--no-browser`. Open the link in a browser on any device and approve. The page then shows a sign-in code, which you paste into the terminal.

**Terminal**

```
$ openemail login --no-browser
Open this link in a browser on any device to sign in:
  https://api.openemail.uk/auth/mcp/authorize?response_type=code&client_id=…
Paste the code from your browser
```

- A code works only for the sign-in that printed the link, so a code from another tab is refused.
- Pasting the whole address the browser ended on works too.
- Without a terminal, pipe the code in on stdin.

## API keys

An API key signs a script in with no browser, and is never asked for a code. Create one in Settings → API keys (`openemail open api-keys`) with only the scopes the script needs. The CLI checks the key with `GET /keys/self` before saving it, and takes `oe_live_` and `oe_test_` keys. Mail sent with a test key is never delivered.

**Terminal**

```
openemail login --with-token < ~/.config/openemail/key
echo "$OPENEMAIL_KEY" | openemail login --with-token --profile ci
openemail login --token oe_live_…
```

`--token` works too, but the key lands in your shell history, so the CLI warns you and suggests `--with-token`. Two ways use a key without saving it:

- `OPENEMAIL_API_KEY` in the environment is used by every command that sees it, ahead of any saved profile.
- `--api-key <key>` is used for that one command.

When there is more than one credential, the first of these wins: `--api-key`, `OPENEMAIL_API_KEY`, the profile named by `--profile`, the profile named by `OPENEMAIL_PROFILE`, then the active profile.

## Profiles

A profile is one saved sign-in, of either kind. The first is called `default`. Sign more in with `--profile`, and switch between them:

**Terminal**

```
openemail login --profile work
openemail profile list
openemail profile use work
openemail inbox --profile default
OPENEMAIL_PROFILE=work openemail status
openemail profile current
openemail profile remove work
```

- `profile list` shows each profile with its kind, workspace and user or key, and marks the active one. Its JSON never includes a token or a key.
- `profile current` prints only the name on stdout, so `$(openemail profile current)` works in a script.
- `profile remove <name>` is the same as `openemail logout --profile <name>`.
- A profile name is up to 64 letters, digits, dots, dashes and underscores.
- `profile use` is also `profile switch`. Removing or signing out of the active profile leaves no profile active, and the next command that needs a sign-in points at `openemail profile use <name>`.

## Which API a sign-in talks to

A saved profile remembers the API it signed in to, and its credential is only ever sent there. A `--base-url` or `OPENEMAIL_BASE_URL` that names another origin stops the command with exit code `2` before anything is sent, and says how to sign in to that origin as a profile of its own.

**Terminal**

```
openemail login --profile other --base-url https://api.example.com
openemail inbox --profile other
```

- A key from `OPENEMAIL_API_KEY` or `--api-key` is not a saved profile, so it goes to the origin in `--base-url` or `OPENEMAIL_BASE_URL`, or to `https://api.openemail.uk` when neither is set.
- Commands that send no credential follow `--base-url` and `OPENEMAIL_BASE_URL` whatever profile is active: disposable inboxes, methods that need no key, `docs` and `open`.
- Plain `http` is refused for every origin except `localhost`, `127.0.0.1` and `::1`, with exit code `2`: the API, the web app, the sign-in, token and revoke requests, and the MCP server. Use `https` for anything else.
- A request path that would leave the API origin, such as `openemail api //example.com/x`, stops with exit code `2` and `invalid_path` before anything is sent.

## What each sign-in cannot do

A browser sign-in acts as you, but some things are never approved for an app, whatever access you choose:

- Managing API keys. `keys:write` and `keys:manage` are never granted, so creating, rotating and revoking keys needs an API key that holds `keys:manage`, or the web app. `openemail me rotate` rotates the key you are calling with, so it takes an API key.
- Billing, and the workspaces themselves. Plans, invoices, and creating, switching or deleting a workspace stay in the web app.
- Your free address. An app is approved for a business workspace, and the personal workspace that holds the free address is never offered, the same rule the API follows.
- Members and roles, unless the approval covers the whole workspace. `members:write` and `roles:write` are dropped from an approval limited to some domains or addresses.

An API key has a limit of its own. `ai compose`, `ai summarize` and every `openemail mcp` command except `config` go through the MCP server, which takes a browser sign-in, so with a key they stop with exit code `4` and say why.

## Verification codes

With a browser sign-in, a few changes ask for a verification code first, as they do in the web app. The CLI asks when it needs one: it emails you a six-digit code, or, when two-factor sign-in is on, asks for a code from your authenticator app or one of your backup codes. Once the code is right the command runs, and that sign-in is not asked again for 60 minutes. An API key is never asked.

| Command | Asks for a code |
| --- | --- |
| webhooks create, update | Always |
| rules create, update | Always |
| roles update, delete | Always |
| members add, update, remove | Always |
| members grant-address, revoke-address | Always |
| domains delete, delete-address | Always |
| audiences delete | For an audience you created |
| audiences empty | For an audience you created that still has contacts |
| mcp call createRule, setRuleEnabled | Always |
| mcp call removeDomain, removeDomainAddress | Always |
| mcp call deleteAudience, emptyAudience | As the matching audience command does |
| api | When the operation it calls is one of the above |

**Terminal**

```
$ openemail webhooks create --url https://acme.com/hooks/openemail
We emailed a code to a•••@acme.com.
Verification code: 482913
Verified. You will not be asked again for 60 minutes.
```

- Type `r` at the prompt to have the email sent again. A wrong code says how many tries are left.
- After the code is accepted the command runs once more, never twice.
- `--yes` confirms a deletion, but never skips a code.
- Unattended (with `--json` or `--no-input`, in CI, or without a terminal) nobody can type the code, so the command stops with exit code `4` and changes nothing.
- A code allows 5 tries, and after the fifth wrong one the CLI offers a new code. Each sign-in can ask for 5 codes an hour and 20 a day.
- Ten wrong codes for one sign-in within 24 hours pause its verification. The CLI then says when it resumes and stops with exit code `4` and `step_up_paused`, without offering another code, and the email explaining it names the app.

Run `openemail verify` before a script or an AI client does something sensitive. It asks for the code now, and for the next 60 minutes every command on that profile runs without one, `openemail mcp call` and the local MCP bridge included.

**Terminal**

```
openemail verify
openemail verify --status
openemail verify --status --json
openemail verify --force
```

> The 60 minutes belong to one sign-in. Another profile, or an AI client that signed in on its own, is asked for its own code, and signing out ends them at once. `--force` asks for a new code and starts a fresh 60 minutes.

- [Verification codes in the API](https://openemail.uk/docs/api/authentication.md): The operations and MCP tools that ask, and the calls that verify a code.

## Expiry, sign-out and revocation

- The access token behind a browser sign-in lasts an hour. The CLI refreshes it before it runs out and saves the new one, so you never see it happen.
- Each refresh token works once. An old one used more than 30 seconds after the CLI replaced it, for example from a copy of `config.json` on another machine, makes the server revoke that sign-in entirely, so sign in on each machine rather than copying the file.
- The approval lasts as long as you chose on the approval page. When it ends, or when the app is removed in Account → Connected apps, the CLI can no longer act for you and asks you to run `openemail login` again.
- `openemail logout` revokes a browser sign-in on the server, which removes it from Connected apps, then forgets it on this device, even when the server cannot be reached. `--all` signs out of every profile.
- Signing out of an API key only forgets it here. The key keeps working until you revoke it, with `openemail keys revoke <id>` or in the web app.

## Where sign-ins are kept

Everything lives in `~/.openemail`, or in the folder `OPENEMAIL_CONFIG_DIR` names. The folder is readable by you alone (`0700`), and so is every file in it (`0600`). Each file is written to a temporary file and renamed into place, so a crash never leaves half of one, and every change is made under a lock file, so commands running side by side never lose a profile.

| File | What it holds |
| --- | --- |
| config.json | Your profiles: API keys, access and refresh tokens, and which profile is active |
| temp-mail.json | The disposable inboxes this CLI created, with their inbox tokens |
| update-check.json | When npm was last asked about a new release, and what it said |

> Tokens and keys are stored as plain text in files only your user can read, so treat the folder as you would an SSH key. A file the CLI cannot make sense of is never read as signed out in silence: it warns once with the path, and keeps a copy beside it (`config.json.bak`) before writing a new one. A file it cannot read at all, for example because of its permissions, stops the command with an error that names it.
