Available since 2.2

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

  1. Enable it. Set signup.requireEmailVerification: true (see configuration).
  2. Sign up. signUp creates the account and Contember mails an EMAIL_VERIFICATION token 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.
  3. Verify. The user submits the token via verifyEmail. The token is single-use and expires after 24 hours.
  4. Sign in. Until the address is verified, signIn for that account returns EMAIL_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.

OptionNotes
mailProjectSlug of the project whose mail template / branding to use. Defaults to the recipient's preferred project.
mailVariantTemplate variant (e.g. locale).
captchaTokenCaptcha 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 reporting ok: 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:

  1. changeMyProfile(email: "new@…") validates and rate-limits the request, then mails an EMAIL_CHANGE_VERIFY token to the new address. The mutation returns ok: true but the address is unchanged. The old address stays active until confirmation. Any name change passed in the same call is applied atomically with the token (validation runs first, so a rejected change never leaves a half-applied profile).
  2. The user clicks through and submits the token via confirmEmailChange.
  3. 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 }
  }
}
CodeCause
TOKEN_NOT_FOUND, TOKEN_INVALID, TOKEN_USED, TOKEN_EXPIREDToken is missing, malformed, already consumed, or older than 24 hours.
EMAIL_ALREADY_EXISTSAnother account claimed the address between request and confirmation. Uniqueness is re-checked at confirmation time and ultimately enforced by the unique index.
INVALID_EMAIL_FORMATThe 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 }
  }
}
FlagDefaultEffect
signup.requireEmailVerificationfalseNew accounts must verify before they can sign in. Captured per account at sign-up; toggling affects only accounts created afterwards.
emailChange.requireVerificationfalseSelf-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:

TypeSent whenVariables
EMAIL_VERIFICATIONsignUp (when required) or requestEmailVerification{{email}}, {{token}}, {{project}}, {{projectSlug}}
EMAIL_CHANGE_VERIFYchangeMyProfile initiates a verified e-mail change (sent to the new address){{email}}, {{token}}, {{project}}, {{projectSlug}}
EMAIL_CHANGE_NOTIFYconfirmEmailChange 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:

TypeWhen
email_verify_initA verification mail is actually sent (signUp or requestEmailVerification)
email_verify_completeverifyEmail
email_change_initchangeMyProfile initiates a verified e-mail change
email_change_completeconfirmEmailChange

Throttled attempts that suppress the mail do not write an *_init entry, so the backoff is not pushed out by phantom inits.