Back to blog

Email verification flows that do not leak account state


Own Auth sets user.emailVerifiedAt only after auth.verifyEmail atomically consumes a valid email-verification token. Provider acceptance, delivery, and a browser opening the link do not change verification state or grant application permissions.

Define one address comparison policy

Own Auth normalizes each email with trim().toLowerCase() before storage. Account lookup, verification, password reset, magic links, and recipient rate limits use that normalized value.

Issue a purpose-bound token

auth.signUpEmailPassword returns the initial session before verification. Call auth.requestEmailVerification separately. It issues a token only when the normalized address belongs to an existing user. An unknown address returns { sent: true, expiresAt: null } without creating a token or sending a message.

For an existing user, the token record contains the userId, normalized email, purpose, expiry, and protected hash. The default lifetime is 24 hours, and requests are limited to five per address every ten minutes. Further requests in that window throw rate_limited with HTTP status code 429. The configured email provider receives the raw URL. By default, the method result contains only sent and expiresAt, not the raw token or URL.

A resend creates another single-use token. In Own Auth, earlier unused links remain valid until they are consumed or expire. Product copy should reflect that behavior instead of promising that only the newest email works. If a stricter replacement policy becomes a product requirement, implement it in the auth package rather than adding delivery-side token state.

Keep the request route quiet

A resend route can derive the address from an authenticated session. A route that accepts an arbitrary address must return the same public result whether the account exists, is already verified, or received a provider acceptance. Own Auth limits the normalized recipient; edge controls can separately constrain one source targeting many recipients.

Do not make delivery status part of the identity model. Queued, accepted, deferred, bounced, and delivered are transport states. Only successful token consumption should set the verification timestamp. This distinction prevents retries, webhook ordering, and provider-specific delivery semantics from changing authentication data.

Consume on the backend

The backend passes the submitted token to auth.verifyEmail. Own Auth returns expired_token for an expired token, token_already_used after consumption, and invalid_token for malformed or wrong-purpose values. When a token is issued, Own Auth records email_verification.requested; successful consumption records email.verified. On success it atomically consumes the token and sets user.emailVerifiedAt; concurrent submissions admit one successful consumer. Verification does not create another session, so an already signed-in user keeps the session established during sign-up.

After token submission, redirect to a URL without the credential. The rendered page, client logs, and monitoring metadata must not echo it. A restrictive referrer policy prevents the verification URL from being sent to another origin before the redirect completes.

Authorize from verified state, not from the click

Own Auth does not block sign-in for an unverified user. The application decides which features require emailVerifiedAt, and that check belongs on the backend path protecting the operation. auth.getCurrentSession returns the current emailVerifiedAt value for that check. A verified email might be required before sending invitations or enabling recovery, but it must not imply organisation membership, an administrative role, tenant access, or permission to change another credential.