Sign-up Required
Prove the email address before trusting it
Mark an email address as verified only after someone opens a link or types a code sent to it. Until then, send no account data there and never use it to link accounts.
How to do it
Store email_verified_at as a time, empty at sign-up. Send a link with a single-use token of at least 128 random bits, valid for 24 hours. A 6-digit code instead lasts at most 15 minutes and dies after 5 wrong tries, because a million guesses are quick. Build the token as the sign-in links rule says, hashed, single use and behind a button page. The link only confirms the address and never signs anyone in, so it may last longer than a sign-in link. People may use the product while unverified, but block invitations, exports, billing email and linking a provider login. Allow a resend after 60 seconds, at most 5 an hour. A verified address proves access to an inbox only, so it never replaces a sign-in factor.
Example
const NEEDS_VERIFIED_EMAIL = new Set(['invite', 'export', 'link-identity', 'billing-email']);
export function assertEmailVerified(user, action) {
if (NEEDS_VERIFIED_EMAIL.has(action) && !user.emailVerifiedAt) {
throw new ForbiddenError('Confirm your email address first. We sent you a link.');
}
}
NEEDS_VERIFIED_EMAIL = {"invite", "export", "link-identity", "billing-email"}
def assert_email_verified(user, action: str) -> None:
if action in NEEDS_VERIFIED_EMAIL and user.email_verified_at is None:
raise Forbidden("Confirm your email address first. We sent you a link.")
How to check
Sign up with a test address and skip the link. Invite a colleague, export data and link a provider login with the same address. Each is refused. Open the link, and all three work.
For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers accounts playbook: https://agentwranglers.se/playbook/accounts/sign-up/email-verification.