Contember can require users to prove ownership of their e-mail address by clicking a link (or pasting a token) that is mailed to that address. Two independent flows are covered:
- Sign-up verification — a newly created account cannot sign in until its e-mail is verified. Controlled by
signup.requireEmailVerification(default off). - E-mail-change verification — a user-initiated e-mail change is not applied immediately; the new address must be confirmed before it becomes active. Controlled by
emailChange.requireVerification(default off).
Each has its own configuration flag. They can be enabled independently, with one coupling: enabling sign-up verification automatically subjects those accounts to change verification too (you cannot require a proven address and then allow it to be swapped for an unproven one). A related, third knob — IdP requireVerifiedEmail — gates auto-linking an external identity to a local account.
All three verification mutations (requestEmailVerification, verifyEmail, confirmEmailChange) are part of the public auth surface: they are gated by the same PERSON_RESET_PASSWORD permission as password reset, so the public login token can call them.
Sign-up verification
Flow
- Enable it. Set
signup.requireEmailVerification: true(see configuration). - Sign up.
signUpcreates the account and Contember mails anEMAIL_VERIFICATIONtoken to the address. The requirement is frozen onto the account at sign-up time — flipping the tenant flag later never retroactively locks out accounts created while verification was optional. - Verify. The user submits the token via
verifyEmail. The token is single-use and expires after 24 hours. - Sign in. Until the address is verified,
signInfor that account returnsEMAIL_NOT_VERIFIED. Accounts that predate the requirement pass straight through.
verifyEmail
Consumes a verification token and marks the address verified.
mutation {
verifyEmail(token: "xyz789…") {
ok
error { code developerMessage }
}
}
Error codes: TOKEN_NOT_FOUND, TOKEN_INVALID, TOKEN_USED, TOKEN_EXPIRED.
requestEmailVerification
(Re)sends the verification link — e.g. behind a "resend e-mail" button, or to verify an existing unverified account.
mutation {
requestEmailVerification(
email: "[email protected]",
options: { mailProject: "my-project", mailVariant: "en-US" },
captchaToken: "0.aXR…"
) {
ok
error { code developerMessage }
}
}
Always returns ok: true regardless of whether the address exists or is already verified — the same anti-enumeration stance as password reset. A mail is only actually sent when the address belongs to an unverified account and the per-recipient backoff allows it.
Like createResetPasswordRequest, this is an unauthenticated, mail-sending endpoint, so it can additionally be guarded by a per-IP rate limit (emailVerificationPerIp) and captcha (captcha.protect.emailVerification). Both are opt-in for this flow (default off) — turn them on if you expose the resend endpoint to anonymous clients. When enabled, those gates run before the anti-enumeration response, so — unlike the silent backoff — they surface an explicit error.
| Option | Notes |
|---|---|
mailProject | Slug of the project whose mail template / branding to use. Defaults to the recipient's preferred project. |
mailVariant | Template variant (e.g. locale). |
captchaToken | Captcha token from the widget. Required only when captcha.protect.emailVerification is enabled. |
Error codes:
RATE_LIMIT_EXCEEDED— the per-IP window is full (or, on the rare observable path, the per-recipient backoff); the backoff normally just suppresses the mail while still reportingok: true.INVALID_CAPTCHA— captcha is configured but the token is missing or rejected.
Sign-in behavior
mutation {
signIn(email: "[email protected]", password: "…") {
ok
error { code }
result { token }
}
}
When the account was created under the requirement and is still unverified, signIn returns EMAIL_NOT_VERIFIED. Your UI should react by offering a "resend verification e-mail" action wired to requestEmailVerification. See sign-in for the full error matrix.
E-mail-change verification
An e-mail change goes through verification when either emailChange.requireVerification is on (it is off by default), or the account is already subject to sign-up verification (email_verification_required, frozen on at sign-up). In other words sign-up verification implies change verification: an account whose address had to be proven cannot silently swap it for an unproven one. In that case, a user changing their own address via changeMyProfile does not swap the address immediately. Instead:
changeMyProfile(email: "new@…")validates and rate-limits the request, then mails anEMAIL_CHANGE_VERIFYtoken to the new address. The mutation returnsok: truebut the address is unchanged. The old address stays active until confirmation. Anynamechange passed in the same call is applied atomically with the token (validation runs first, so a rejected change never leaves a half-applied profile).- The user clicks through and submits the token via
confirmEmailChange. - On success, the address is swapped, every existing session is signed out (an e-mail change is a takeover-grade event), and a notification (
EMAIL_CHANGE_NOTIFY) is mailed to the old address.
Because the link goes to the new address, clicking it proves the user controls the address being switched to.
When neither condition applies — the flag is off and the account is not verification-required (always the case on a deployment that never enabled any verification) — changeMyProfile swaps the address immediately as before, so an engine upgrade does not change existing behavior.
The admin changeProfile mutation is never routed through verification — admins are trusted by policy and the change is applied directly. It does, however, clear the verified status when it changes the address: the new address is unproven, so email_verified_at is reset (which, for a verification-required account, makes the user re-verify before their next sign-in).
confirmEmailChange
mutation {
confirmEmailChange(token: "xyz789…") {
ok
error { code developerMessage }
}
}
| Code | Cause |
|---|---|
TOKEN_NOT_FOUND, TOKEN_INVALID, TOKEN_USED, TOKEN_EXPIRED | Token is missing, malformed, already consumed, or older than 24 hours. |
EMAIL_ALREADY_EXISTS | Another account claimed the address between request and confirmation. Uniqueness is re-checked at confirmation time and ultimately enforced by the unique index. |
INVALID_EMAIL_FORMAT | The pending address no longer passes validation. |
IdP verified-email gate
Set requireVerifiedEmail: true in an IdP's options to refuse auto-linking an external identity to a pre-existing local account by e-mail unless the provider asserts the e-mail is verified:
mutation {
updateIDP(
identityProvider: "oidc-provider",
options: { autoSignUp: true, exclusive: false, requireVerifiedEmail: true }
) { ok error { code } }
}
Auto-linking by e-mail is takeover-grade: a provider returning an unverified (or attacker-controlled) address could otherwise hijack a local account. With this option on, an unverified claim simply fails to link / sign in. Defaults to false. Only relevant for non-exclusive providers (exclusive providers never link by e-mail). See IdP.
Configuration
mutation {
configure(config: {
signup: { requireEmailVerification: true },
emailChange: { requireVerification: true }
}) { ok error { code developerMessage } }
}
Read the current state via the configuration query:
query {
configuration {
signup { requireEmailVerification }
emailChange { requireVerification }
}
}
| Flag | Default | Effect |
|---|---|---|
signup.requireEmailVerification | false | New accounts must verify before they can sign in. Captured per account at sign-up; toggling affects only accounts created afterwards. |
emailChange.requireVerification | false | Self-service e-mail changes go through confirmEmailChange instead of swapping immediately. Sign-up-verified accounts are subject to this regardless of the flag. |
The two can be set independently of each other and of the IdP gate, with the one coupling noted above (sign-up verification implies change verification). Requires the CONFIGURE permission (SUPER_ADMIN by default). See configuration.
Mail templates
Three mail types back these flows — all customizable via mail templates:
| Type | Sent when | Variables |
|---|---|---|
EMAIL_VERIFICATION | signUp (when required) or requestEmailVerification | {{email}}, {{token}}, {{project}}, {{projectSlug}} |
EMAIL_CHANGE_VERIFY | changeMyProfile initiates a verified e-mail change (sent to the new address) | {{email}}, {{token}}, {{project}}, {{projectSlug}} |
EMAIL_CHANGE_NOTIFY | confirmEmailChange succeeds (sent to the old address) | {{email}}, {{newEmail}} |
Observing verification state
The current account's verification status is exposed on me:
query { me { person { emailVerified } } }
Rate limiting
Both requestEmailVerification and the changeMyProfile e-mail-change path apply a per-recipient exponential backoff so a resend button (or an attacker poking the endpoint) cannot flood a mailbox. The backoff reuses the login.baseBackoff / login.maxBackoff / login.attemptWindow knobs (see configuration) and is keyed on the normalized target address. While throttled, the mutation still reports ok: true but the mail is suppressed until the next allowed attempt.
Audit
Each flow writes paired init / complete entries to the audit log:
| Type | When |
|---|---|
email_verify_init | A verification mail is actually sent (signUp or requestEmailVerification) |
email_verify_complete | verifyEmail |
email_change_init | changeMyProfile initiates a verified e-mail change |
email_change_complete | confirmEmailChange |
Throttled attempts that suppress the mail do not write an *_init entry, so the backoff is not pushed out by phantom inits.