# Email Domain Allowlist

Restrict which email domains can own an account in your Volcano project, and choose whether the restriction covers signup only or sign-in as well.

Restrict which email domains can own an account in your project, and decide
whether accounts that predate the restriction may keep signing in.

**SUPERAGENT plan required.**
**Settings:** `allowed_email_domains`, `allowed_email_domains_mode`
**Types:** Array of strings, string
**Defaults:** `[]` (any domain), `"signup"`

Use it for internal tools, customer-specific deployments, or any app where only
people with a company address should be able to join.

## Enable

Set both settings from the Dashboard, the API, or `volcano-config.yaml`. An empty
list accepts any domain.

The allowlist is a SUPERAGENT feature, to set and to enforce. A HOBBY project that asks
for one gets `403 the email domain allowlist is only available on the PRO plan`,
and a project that downgrades keeps its domains but stops enforcing them — see
[Plans](#plans).

**Dashboard:** in **Auth Settings → General**, under **Email Domain Allowlist**,
enter the domains one per line and choose where the list applies: **New signups
only** (`signup`), **Signups and sign-ins** (`signup_and_signin`), or **Not
enforced** (`disabled`). Picking **Signups and sign-ins** asks you to confirm
first, because saving it signs out every account outside the list.

**API:**

```bash
curl -X PUT https://api.volcano.dev/projects/PROJECT_ID/auth/config \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "allowed_email_domains": ["domain1.com", "domain2.com"],
    "allowed_email_domains_mode": "signup_and_signin"
  }'
```

**`volcano-config.yaml`:**

```yaml
auth:
  signup:
    allowed_email_domains:
      - domain1.com
      - domain2.com
    allowed_email_domains_mode: signup_and_signin
```

## Modes

`allowed_email_domains_mode` decides how far the list reaches. An empty list
restricts nothing whatever the mode is.

| Mode                | Signup                   | Sign-in                  | Existing sessions                  |
| ------------------- | ------------------------ | ------------------------ | ---------------------------------- |
| `signup` (default)  | Blocked outside the list | Allowed                  | Kept                               |
| `signup_and_signin` | Blocked outside the list | Blocked outside the list | Revoked for the sign-ins it blocks |
| `disabled`          | Allowed                  | Allowed                  | Kept                               |

Pick `signup` to stop new accounts while letting everyone who already signed up
carry on. Pick `signup_and_signin` to cut off accounts outside the list too —
this is the setting that turns the allowlist into an access control rather than
a registration filter. Pick `disabled` to park the list without deleting it, so
you can switch the same domains back on later.

## What It Covers

Every path that would give an address an account is checked under `signup` and
`signup_and_signin`:

| Path                                                                 | Behavior with a disallowed domain                                       |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `POST /auth/signup`                                                  | 403, no user created                                                    |
| OAuth/SSO callback creating a new user                               | 403, no user created                                                    |
| OAuth/SSO callback claiming an existing unconfirmed account by email | 403                                                                     |
| `POST /auth/oauth/{provider}/link`                                   | 403 when the provider address would become a new address on the account |
| `POST /auth/user/convert-anonymous`                                  | 403, the user stays anonymous                                           |
| `POST /auth/user/change-email`                                       | 403, the address is unchanged                                           |
| `POST /auth/user/confirm-email-change`                               | 403 if the allowlist narrowed after the change was requested            |
| `POST /auth/user/methods/{methodId}/promote`                         | 403, the account keeps its current primary                              |
| Anonymous signup (no email)                                          | Allowed — nothing to check                                              |

Promotion is on that list because it re-derives the account's canonical email
from the promoted identity. An account can hold an identity that predates the
restriction, and promoting it would move the account onto an address signup
would refuse.

Unlinking the account's primary provider (`DELETE /auth/oauth/{provider}/unlink`)
also moves its canonical email to another identity. While the list is enforced,
an identity whose address is on the list goes first, so the account keeps an
allowed canonical email whenever it has one.

Under `signup_and_signin`, every path that hands out a session is checked as
well:

| Path                                             | Behavior with a disallowed domain                      |
| ------------------------------------------------ | ------------------------------------------------------ |
| `POST /auth/signin`                              | 403, no session issued                                 |
| OAuth/SSO callback for an already-linked account | 403                                                    |
| `POST /auth/oauth/exchange`                      | 403                                                    |
| `POST /auth/device/token`                        | 403 `access_denied`                                    |
| `POST /auth/refresh`                             | 403, the session cannot be extended                    |
| `POST /auth/platform/exchange`                   | 403, no CLI platform token is minted                   |
| Anonymous sign-in                                | Allowed — anonymous accounts carry an internal address |

Linking a provider whose address the account already owns keeps working, so a
user grandfathered in before the restriction can still add a provider.

A session needs two addresses on the list:

- the account's **canonical email** — the address of its primary identity, the
  one `GET /auth/user` returns
- the email of the identity it signs in with — the password identity for
  `POST /auth/signin`, the provider's identity for an OAuth/SSO sign-in

Both are checked when the session is issued and every time it is refreshed. A
session that does not sign in through one identity, such as a CLI device login,
needs every identity on the account to be on the list. An identity with no
email is outside the list. Session revocation applies the same rule, so the two
never disagree.

The error message is `email domain is not allowed for this project`.

## Matching Rules

Matching is an exact, case-insensitive comparison of the part after the last
`@`:

| Allowlist          | Address                     | Result   |
| ------------------ | --------------------------- | -------- |
| `domain1.com`      | `user@domain1.com`          | Allowed  |
| `domain1.com`      | `User@DOMAIN1.COM`          | Allowed  |
| `domain1.com`      | `user+tag@domain1.com`      | Allowed  |
| `domain1.com`      | `user@mail.domain1.com`     | Rejected |
| `domain1.com`      | `user@domain1.com.evil.com` | Rejected |
| `domain1.com`      | `user@domain1.com.`         | Allowed  |
| `xn--bcher-kva.de` | `user@bücher.de`            | Allowed  |

Subdomains are not implied. List `mail.domain1.com` explicitly if you want to
accept it. Domains are compared after IDNA mapping, so list an
internationalized domain in its ASCII (`xn--`) form; it matches addresses
written in either form.

## Entry Format

Entries are normalized before they are stored, so the forms people paste all
converge:

| You write         | Stored as     |
| ----------------- | ------------- |
| `@Domain1.com`    | `domain1.com` |
| `  DOMAIN1.com  ` | `domain1.com` |
| `domain1.com.`    | `domain1.com` |

Duplicates are dropped and the configured order is kept. An entry must be a bare
domain with a top-level domain of at least two letters, so `localhost`,
`user@domain1.com`, and `domain 1.com` are rejected with a 400 when you save.
The list holds at most 100 domains.

## Existing Users

Under `signup` and `disabled`, adding a restriction never locks anyone out.
Users whose domain is no longer on the list keep signing in normally; the
allowlist only governs new accounts and email changes.

Under `signup_and_signin`, saving the policy signs out every session the list
refuses right away — those sessions are deleted, so their access tokens stop
working on the next request rather than at expiry. The accounts themselves stay,
so putting the domain back restores access; delete the users if you need them
gone for good.

Nobody is signed out while the list is parked on a HOBBY project: sign-in admits
the same accounts, so ending their sessions would only interrupt users the
project can no longer keep out.

Saving and signing out are one operation: if the sign-out fails, the save fails
with it and the previous policy stays in force, so a project never advertises a
lock it has not applied. A sign-in that was already in flight when you saved can
still land a session — it read the old policy — but it cannot be refreshed or
exchanged for a CLI token, so it ends when its access token expires.

One exception to "right away": a Realtime connection that is already open is not
closed, because its session was checked when it connected. It stops working as
soon as it reconnects. Close those connections from your app if you need the
lock to reach live subscribers immediately.

Anonymous accounts are never affected: whether they may exist at all is governed
by [anonymous users](https://docs.volcano.dev/platform/authentication/anonymous-users.md) instead. The exemption follows the
account being anonymous, not the address it carries — `anonymous.volcano.internal`
is reserved for accounts the platform mints, and a signup asking for an address
inside it is refused with the same 403 whatever the mode, including `disabled`.

Builder debug users (`*@volcano.local`, created by the project owner from the
dashboard) are provisioned directly and never pass through the signup check.
Under `signup_and_signin` their sessions are checked like any other account's,
so a debug run acting as one is refused. To debug as one, add `volcano.local`
to `allowed_email_domains`.

`volcano.local` is reserved like `anonymous.volcano.internal`: a signup, email
change or OAuth account at that domain is refused with 403 in every project and
mode. Listing it admits every existing account at the domain, which means the
debug users plus any account that took a `volcano.local` address before Volcano
reserved it. Check `GET /projects/{id}/auth/users` for such accounts before you
list it.

## Remove the Restriction

Two ways, depending on whether you want to keep the list:

```bash
# Park the list — the domains stay, nothing is enforced.
curl -X PUT https://api.volcano.dev/projects/PROJECT_ID/auth/config \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"allowed_email_domains_mode": "disabled"}'

# Clear the list — the domains are gone.
curl -X PUT https://api.volcano.dev/projects/PROJECT_ID/auth/config \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"allowed_email_domains": []}'
```

In `volcano-config.yaml`, `allowed_email_domains: []` clears the list; omitting
a key leaves the stored value untouched. From the Dashboard, **Not enforced**
parks the list and emptying the field clears it.

Both work on any plan — see below.

## Plans

The allowlist is a SUPERAGENT feature, to configure and to enforce.

A project that drops to HOBBY keeps its domains — nothing is deleted, and
`GET /auth/config` still returns them — but they stop deciding anything: signups
and sign-ins from any domain go through again, and the sessions the policy would
have ended are left alone. Upgrading to SUPERAGENT puts the stored list straight back
to work, without anyone re-saving it. Plan changes reach the API within a few
seconds.

**Downgrading reopens signups you closed.** If the list is the only thing
keeping strangers out of your app, disable signup
([`enable_signup: false`](https://docs.volcano.dev/platform/authentication/configuring-auth-methods.md)) before you downgrade.

What a HOBBY project can do to the list it configured while on SUPERAGENT:

| Change                                                                      | HOBBY                     |
| --------------------------------------------------------------------------- | ------------------------- |
| Save the policy it already has (a `volcano-config.yaml` apply, for example) | Allowed — nothing changes |
| Park the list (`disabled`), leaving the domains as they are                 | Allowed                   |
| Clear the list (`[]`)                                                       | Allowed                   |
| Add or replace a domain, parked or not                                      | `403`, nothing is saved   |
| Switch to `signup_and_signin`, or turn a parked list back on                | `403`                     |

The rule behind the table: a HOBBY project may move toward no restriction, never
away from it. That keeps a downgrade from stranding a project behind a list it
cannot clear, while still requiring SUPERAGENT to configure one. A parked list counts
as the allowlist for this — nothing but a mode change and an upgrade stand
between it and enforcement — so the only edit HOBBY can make to the domains is
dropping them.

## Troubleshooting

**Signup returns 403 `email domain is not allowed for this project`**
The address is outside the list. Add its domain, or clear the list.

**Sign-in returns 403 `email domain is not allowed for this project`**
The project is in `signup_and_signin` and either the account's canonical email
or the email of the identity you signed in with is not listed. Check both: the
canonical email is the one `GET /auth/user` returns, and the identities are
listed by `GET /auth/user/identities`. Add the domain, or switch the mode back
to `signup` to let existing accounts in.

**Signup returns 403 for an `@anonymous.volcano.internal` or `@volcano.local` address**
Those domains are reserved for the anonymous and Builder debug accounts the
platform creates. No project can accept a signup at either, whatever the
allowlist says.

**Users were signed out after a config change**
Expected under `signup_and_signin`: saving the policy revokes every session the
list refuses.

**A subdomain is rejected even though the parent domain is listed**
Expected — add the subdomain as its own entry.

**Saving returns 400 `invalid allowed_email_domains entry`**
An entry is not a bare domain. Use `domain1.com`, not `user@domain1.com`,
`https://domain1.com`, or `localhost`.

**Saving returns 400 `invalid allowed_email_domains_mode`**
The mode must be `disabled`, `signup`, or `signup_and_signin`.

**Saving returns 403 `the email domain allowlist is only available on the PRO plan`**
The project is on HOBBY. Upgrade to SUPERAGENT to set or widen the list. A project that
was on SUPERAGENT keeps the list it saved, unenforced, and can still remove it.

**An outside domain signed up even though the list is set**
Check the plan. On anything but SUPERAGENT the list is parked: it stays in the config
and stops being enforced. Upgrade to put it back in force.

## Related

- [Provider Separation](https://docs.volcano.dev/platform/authentication/configuration/provider-separation.md) — which providers can create users
- [Configuring Auth Methods](https://docs.volcano.dev/platform/authentication/configuring-auth-methods.md) — enabling and disabling signup
- [CORS](https://docs.volcano.dev/platform/authentication/configuration/cors.md) — which sites can call your auth endpoints
