Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ mutation UpdateUser {
}

mutation DeleteUser {
_delete_user(params: { email: "signup.test134523@yopmail.com" }) {
_delete_user(params: { id: "b1e2c3d4-5678-90ab-cdef-1234567890ab" }) {
message
}
}
Expand Down
196 changes: 196 additions & 0 deletions docs/core/email-verification-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
---
sidebar_position: 7
title: Email Verification Contract
---

# Email verification contract

Authorizer resolves a local account from an email address in several flows —
password signup, magic link, and every social/enterprise federated login. This
page states when an address counts as **verified**, why federated logins are
held to that bar, and what you need to configure.

:::warning Behaviour change in 2.4.0

A social login whose provider does not attest the email address is now refused.
See [Upgrading](#upgrading) for what to configure if this affects your
deployment.
:::

## Why an address must be attested

OAuth 2.0 carries no identity claims at all. OpenID Connect added `email` — and
alongside it a **separate `email_verified` boolean**, precisely because `email`
on its own proves nothing about who controls the mailbox.

That distinction matters here because the email address is what selects the
local account. It decides signup-versus-login, and the login branch merges the
incoming federated identity into whatever account already holds the address. If
an attacker can make a provider assert an address they do not own, they land in
that account's session.

This is the [nOAuth](https://www.descope.com/blog/post/noauth) attack class.
Microsoft Entra is the sharp case:

- Entra **v2 ID tokens carry no `email_verified` claim at all**.
- Entra's `email` is a *mutable, unverified* directory attribute — any tenant
admin can set it to any string, including someone else's address.
- The multi-tenant endpoints (`common`, `organizations`, `consumers`) sign with
Microsoft's **global** keys, so a token minted in a free attacker-owned tenant
has a valid signature and a valid `aud`. Only the tenant distinguishes it.

So: register a free Entra tenant, set a user's `email` to `victim@example.com`,
click "Login with Microsoft", and without this contract you are in the victim's
account.

## The three connection classes

Authorizer treats connections the same way Auth0 does:

| Connection class | Is the address attested? | Behaviour |
|---|---|---|
| **Database** (password signup) | No — nobody has vouched for it | `email_verified` is `false` until the user clicks the verification link Authorizer mails them (requires `--enable-email-verification=true`; with verification disabled the address is marked verified at signup) |
| **Social** (Google, Apple, GitHub, …) | Usually yes — the provider vouches | The provider's own signal is imported directly; no separate verification round-trip |
| **Enterprise / Azure AD / OIDC** | **Not guaranteed** — enterprise directories do not promise it | Requires an explicit trust decision from you (see below) |

## Per-provider signal

Each provider is read from the signal that provider actually emits. There is no
single claim that works everywhere, and treating one provider's silence as
another's "true" is exactly what creates the vulnerability.

| Provider | Signal | Notes |
|---|---|---|
| Google | `email_verified` (ID token) | Standard OIDC |
| Apple | `email_verified` (ID token) | Documented as "a string or Boolean value" — both forms accepted |
| Twitch | `email_verified` (ID token) | Standard OIDC |
| LinkedIn | `email_verified` (userinfo) | Both bool and quoted-string forms accepted |
| Microsoft | `xms_edov`, **or** a trusted tenant | No `email_verified` exists on Entra v2 — see below |
| GitHub | verified by construction | Both the public `/user` email and the `/user/emails` fallback are filtered to verified addresses |

## How a user verifies

With `--enable-email-verification`, `signup` creates the verification request
and sends the mail before returning "Verification email has been sent. Please
check your inbox". Clicking that link is the normal path and needs nothing else.

**The link is valid for 30 minutes.** Expiry is enforced when the token is
validated, so an expired link is refused even if the underlying request row is
still there.

:::info Requesting a new link invalidates the old one

A link can also stop working *before* its 30 minutes are up. Each request
rotates a nonce, and redemption checks the token's nonce against the stored
row — so **the most recent link is always the only valid one**.

If a user clicks an older link after requesting a fresh one, it will be
refused. That is intended, and it is the usual explanation for "the link in my
inbox doesn't work" when the user has more than one.
:::

If it expires or never arrives:

| Route | Who drives it | Notes |
|---|---|---|
| **`resend_verify_email`** | the user | The primary recovery. Mints a fresh link for the same address, and mints one even when no pending request remains. Gated on the address actually being unverified, so it cannot be used as an open mailer. |
| **Password login** | the user | An unverified account's password login emails an OTP instead; verifying that OTP marks the address verified. |
| **`_update_user { email_verified: true }`** | an admin | The escape hatch when the user genuinely cannot receive mail. |
| Forgot password | the user | Completing a token reset also verifies the address — a side effect of proving mailbox control, not the route to reach for. |

None of these needs an administrator except the last. Prefer **Resend
Verification Email** over force-verifying: it has the user prove control rather
than asserting it on their behalf.

The response to a resend is deliberately generic ("if a verification is
pending …") and identical whether or not the address exists, so the endpoint
cannot be used to test which addresses are registered.

## Hard requirement: verification needs a working email service

:::danger

`--enable-email-verification=true` with no SMTP configured is a **fatal startup
error** in 2.4.0.

Every route in the table above terminates at the same mailbox, so without a mail
path a user is created unverified and can never recover — and an unverified
account also blocks a federated login for that address.

Set `--smtp-host`, `--smtp-port` and `--smtp-sender-email` — all three — or turn
email verification off.
:::

## This is not the same as Auth0's post-login email check

A post-login Action like:

```js
exports.onExecutePostLogin = async (event, api) => {
if (!event.user.email_verified) {
api.access.deny('Please verify your email address before logging in.');
}
};
```

is a **login policy**: "should this user, whoever they are, be let in before
confirming their own address?" It is reasonably opt-in, and Authorizer's
equivalent is `--enable-email-verification`.

What this page describes is **identity resolution**: "which local account does
this federated assertion refer to?" Getting that wrong does not inconvenience
the legitimate user — it hands their account to somebody else. Which is why the
default is secure and the escape hatch is narrowed rather than total.

## What a refusal looks like

The callback returns `400` before any local account lookup, so no account is
created and no existing account is touched:

```json
{
"error": "email_not_verified",
"error_description": "The identity provider did not confirm that you own this email address."
}
```

An `oauth_email_unverified` security metric and an audit event are recorded.

## Upgrading

Most deployments need no change — Google, Apple, GitHub, Discord, Facebook,
LinkedIn, Twitch, Twitter and Roblox all supply a signal already.

**If you use Microsoft login**, pick one:

- pin `--microsoft-tenant-id` to your tenant (single-tenant deployments — the
common case, and the best option);
- set `--microsoft-allowed-tenants` to the tenants you serve (multi-tenant SaaS);
- enable the `xms_edov` optional claim in your Entra app registration.

### Compatibility mode

If you need more time, `--oauth-allow-unverified-provider-email=true` is a
stopgap — existing users keep working and cross-credential takeover stays
blocked.

It does **not** disable the check. An unattested address still cannot cross into
an account owned by another credential; it only re-permits same-provider
linking, which leaves two Entra tenants able to collide on one address. The
server logs a warning on every boot while it is set.

It is not a substitute for one of the three fixes above.

## Operator actions in the dashboard

The Users table exposes both operator routes per user, shown only when the
relevant identifier is actually unverified:

- **Mark Email Verified** — asserts the address is good without mailing
anything. Sends only `email_verified`; deliberately not `email`, since that
param drives the change-address flow (which clears verification and mails a
new link).
- **Resend Verification Email** — mails a fresh link so the user proves it
themselves. Preferred when you have no independent reason to trust the
address.
- **Mark Phone Verified** — the phone equivalent.
17 changes: 13 additions & 4 deletions docs/core/graphql-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1242,15 +1242,24 @@ mutation {

#### `_delete_user`

Mutation to delete user. This mutation is only allowed for super admins. It accepts `params` of type `DeleteUserInput` with following keys
Mutation to delete user. This mutation is only allowed for super admins. It accepts `params` of type `DeleteUserRequest` with following keys

> Note: the super admin query can be access via special header with super admin secret (this is set via ENV) or `authorizer-admin` as http only cookie.

**Request Params**

| Key | Description | Required |
| ------- | ---------------------------------------------------- | -------- |
| `email` | Email of user that needs to be removed from platform | true |
| Key | Description | Required |
| ---- | ------------------------------------------------- | -------- |
| `id` | ID of user that needs to be removed from platform | true |

:::warning Breaking in 2.4.0

This took `email` and now takes `id` only. Email is not an identifier every
account has — a phone-only signup has none — so an email-keyed delete could not
reach those accounts at all, and there was no second identifier accepted.

Pass the `id` you already have from `_users` or `_user`.
:::

This mutation returns `Response` type with following keys

Expand Down
10 changes: 9 additions & 1 deletion docs/core/grpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,15 @@ The `AuthorizerAdminService` is served on the same gRPC port and address as `Aut

#### `DeleteUser`

*Admin-only.* Delete a user by email and all associated OTP/verification data. Mirrors [`_delete_user`](./graphql-api#_delete_user).
*Admin-only.* Delete a user by id and all associated OTP/verification data. Mirrors [`_delete_user`](./graphql-api#_delete_user).

:::warning Breaking in 2.4.0

`DeleteUserRequest.email` (field 1) is removed and replaced by `id` (field 2).
Field 1 is **reserved**, not reused: both are strings, so reusing the tag would
let an old client's email decode silently as an id on a delete path. Reserving
makes an old client fail loudly instead.
:::

#### `VerificationRequests`

Expand Down
14 changes: 10 additions & 4 deletions docs/core/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,13 +516,19 @@ Update user profile fields (email, roles, name, etc.).

#### `POST /v1/admin/delete_user`

Delete a user by email (and all associated OTP/verification data).
Delete a user by id (and all associated OTP/verification data).

**Request body**

| Field | Type | Description | Required |
| ------- | -------- | ----------- | -------- |
| `email` | `string` | User email. | yes |
| Field | Type | Description | Required |
| ----- | -------- | ----------- | -------- |
| `id` | `string` | User id. | yes |

:::warning Breaking in 2.4.0

This took `email` and now takes `id` only — a phone-only account has no email,
so it could not be deleted at all.
:::

**Response** `{ message: string }`

Expand Down
57 changes: 56 additions & 1 deletion docs/core/server-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,23 @@ Organization / UI:
These replace v1 flags such as `DISABLE_BASIC_AUTHENTICATION`, `DISABLE_EMAIL_VERIFICATION`, etc.
See the [Auth behavior mapping](../migration/v1-to-v2#auth-behavior) for exact correspondences.

:::danger Breaking in 2.4.0 — `--enable-email-verification` requires SMTP

Starting the server with `--enable-email-verification=true` and no working mail
path is now a **fatal boot error**. It used to start and fail silently per user.

Every route back into an unverified account terminates at the same mailbox, so
without SMTP a user is created unverified and can never recover — and an
unverified account also blocks a federated login for the same address. That is
unrecoverable state, so it fails where an operator can see it rather than one
user at a time.

A "working mail path" means all three of `--smtp-host`, `--smtp-port` (greater
than zero) and `--smtp-sender-email`. Setting only the host still fails.

Either configure SMTP or set `--enable-email-verification=false`.
:::

### Multi-factor authentication (MFA) & WebAuthn/passkeys

**Breaking change**: `--enable-mfa`, `--enable-totp-login`, `--enable-email-otp`,
Expand Down Expand Up @@ -296,14 +313,52 @@ Each provider uses its own set of flags:
Other supported providers follow the same pattern:

- `--facebook-client-id`, `--facebook-client-secret`, `--facebook-scopes`
- `--microsoft-client-id`, `--microsoft-client-secret`, `--microsoft-tenant-id`, `--microsoft-scopes`
- `--microsoft-client-id`, `--microsoft-client-secret`, `--microsoft-tenant-id`, `--microsoft-allowed-tenants`, `--microsoft-scopes`
- `--apple-client-id`, `--apple-client-secret`, `--apple-scopes`
- `--linkedin-client-id`, `--linkedin-client-secret`, `--linkedin-scopes`
- `--discord-client-id`, `--discord-client-secret`, `--discord-scopes`
- `--twitter-client-id`, `--twitter-client-secret`, `--twitter-scopes`
- `--twitch-client-id`, `--twitch-client-secret`, `--twitch-scopes`
- `--roblox-client-id`, `--roblox-client-secret`, `--roblox-scopes`

### Provider email attestation (2.4.0)

:::warning Breaking in 2.4.0

A social login whose provider does **not** attest the email address no longer
signs the user into an existing account with that address.

OAuth proves the user holds an account at the provider. It proves nothing about
the email address that provider hands back — which is why OIDC has a separate
`email_verified` claim. Microsoft Entra is the practical case: on a multi-tenant
alias the `email` claim is **mutable and unattested**, so anyone able to set a
directory attribute in *their own* tenant could assert your address and be
logged into your account. This is the [nOAuth](https://www.descope.com/blog/post/noauth)
class of account takeover.

Signup with an unattested address still works — it just creates its own account
instead of reaching an existing one.
:::

Attestation comes from the provider's `email_verified` claim, or Entra's
`xms_edov` optional claim. To make Entra attest:

- pin `--microsoft-tenant-id` to a single tenant, **or**
- enable the `xms_edov` optional claim on the app registration, **or**
- list the tenants you trust in `--microsoft-allowed-tenants` when
`--microsoft-tenant-id` is a multi-tenant alias (`common`, `organizations`,
`consumers`). Empty means no restriction — and in that mode the tenant is
untrusted, so the email it asserts will not link to an existing account.

`--oauth-allow-unverified-provider-email=true` is a temporary compatibility
escape hatch. It does **not** disable the check: an unattested address still
cannot cross into an account owned by another credential. It only re-permits
same-provider linking, which leaves two Entra tenants able to collide on one
address. The server logs a warning on every boot while it is set.

See [Email verification contract](./email-verification-contract) for the
per-provider signal table and the upgrade path.

---

## 8. Rate limiting
Expand Down
1 change: 1 addition & 0 deletions sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const sidebars: SidebarsConfig = {
'core/client-registry',
'core/sso-guide',
'core/email',
'core/email-verification-contract',
'core/rate-limiting',
'core/metrics-monitoring',
],
Expand Down