Email Verification Flow (Step-by-Step)

The essential playbook for implementing email verification flow (step-by-step) in your SaaS.

Set up email verification so new users confirm ownership of their email address before accessing protected features. This page covers the standard flow for Flask or FastAPI apps: create an unverified user, issue a signed verification token, email a verification link, mark the user as verified on callback, and block or limit access until verification completes.

Quick Fix / Quick Setup

python
# Minimal email verification flow
# Works as a reference pattern for Flask/FastAPI apps

from datetime import datetime, timedelta, timezone
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from passlib.context import CryptContext
import os

SECRET_KEY = os.environ['SECRET_KEY']
APP_BASE_URL = os.environ['APP_BASE_URL']  # e.g. https://app.example.com
EMAIL_VERIFY_SALT = os.environ.get('EMAIL_VERIFY_SALT', 'email-verify')

serializer = URLSafeTimedSerializer(SECRET_KEY)
pwd = CryptContext(schemes=['bcrypt'], deprecated='auto')

# Example user record fields:
# id, email, password_hash, is_email_verified, email_verified_at

def create_user(email: str, password: str, db):
    user = {
        'email': email.strip().lower(),
        'password_hash': pwd.hash(password),
        'is_email_verified': False,
        'email_verified_at': None,
    }
    db.insert_user(user)
    send_verification_email(user['email'])
    return user


def generate_verification_token(email: str) -> str:
    return serializer.dumps({'email': email}, salt=EMAIL_VERIFY_SALT)


def verify_token(token: str, max_age_seconds: int = 86400):
    return serializer.loads(token, salt=EMAIL_VERIFY_SALT, max_age=max_age_seconds)


def send_verification_email(email: str):
    token = generate_verification_token(email)
    verify_url = f"{APP_BASE_URL}/auth/verify-email?token={token}"
    subject = 'Verify your email'
    body = f'Click to verify: {verify_url}'
    # send_email(to=email, subject=subject, body=body)
    print(body)


def confirm_email(token: str, db):
    try:
        data = verify_token(token)
    except SignatureExpired:
        return {'ok': False, 'error': 'expired'}
    except BadSignature:
        return {'ok': False, 'error': 'invalid'}

    email = data['email'].strip().lower()
    user = db.find_user_by_email(email)
    if not user:
        return {'ok': False, 'error': 'not_found'}

    if user['is_email_verified']:
        return {'ok': True, 'status': 'already_verified'}

    db.update_user(email, {
        'is_email_verified': True,
        'email_verified_at': datetime.now(timezone.utc),
    })
    return {'ok': True, 'status': 'verified'}


def can_access_app(user):
    return bool(user['is_email_verified'])

Use signed, expiring tokens. Do not store raw verification tokens in the database unless you need single-use revocation. Normalize email addresses, gate sensitive routes until verified, and add a resend-verification endpoint with rate limiting.

Example environment config:

bash
export SECRET_KEY='replace-me'
export APP_BASE_URL='https://app.example.com'
export EMAIL_VERIFY_SALT='email-verify'
export SMTP_HOST='smtp.example.com'
export SMTP_PORT='587'
export SMTP_USERNAME='user'
export SMTP_PASSWORD='pass'

Example route shape:

python
# FastAPI example
from fastapi import FastAPI, HTTPException, Depends

app = FastAPI()

@app.get("/auth/verify-email")
def verify_email(token: str):
    result = confirm_email(token, db)
    if not result["ok"] and result["error"] == "expired":
        raise HTTPException(status_code=400, detail="Verification link expired")
    if not result["ok"] and result["error"] == "invalid":
        raise HTTPException(status_code=400, detail="Invalid verification link")
    return result

@app.post("/auth/resend-verification")
def resend_verification(current_user=Depends(get_current_user)):
    if current_user["is_email_verified"]:
        return {"ok": True, "status": "already_verified"}
    send_verification_email(current_user["email"])
    return {"ok": True, "status": "sent"}

What’s happening

  • The user registers with email and password.
  • The app creates the account with is_email_verified=false.
  • The app sends a verification email containing a signed, time-limited link.
  • The user clicks the link and hits a verification endpoint.
  • The backend validates the token, finds the user, and marks the account as verified.
  • Protected routes, billing access, and account actions can require a verified email.
  • register
    send email
    click link
    verify
    unlock access

    sequence diagram for register -> send email -> click link -> verify -> unlock access.

Step-by-step implementation

1) Add fields to the user model

Minimum fields:

  • is_email_verified boolean default false
  • email_verified_at nullable timestamp

Example SQL:

sql
ALTER TABLE users
ADD COLUMN is_email_verified BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN email_verified_at TIMESTAMPTZ NULL;

If you allow email changes before verification, add:

sql
ALTER TABLE users
ADD COLUMN pending_email TEXT NULL;

2) Normalize email addresses consistently

Use the same normalization on:

  • registration
  • login
  • verification lookup
  • resend endpoint
  • password reset

Example helper:

python
def normalize_email(email: str) -> str:
    return email.strip().lower()

If normalization is inconsistent, token verification may succeed but user lookup may fail.

For base auth setup, see Implement User Authentication (Login/Register).

3) Generate a signed, expiring token

For MVPs, itsdangerous.URLSafeTimedSerializer is enough.

python
from itsdangerous import URLSafeTimedSerializer
import os

serializer = URLSafeTimedSerializer(os.environ["SECRET_KEY"])

def generate_verification_token(email: str) -> str:
    return serializer.dumps(
        {"email": normalize_email(email)},
        salt=os.environ.get("EMAIL_VERIFY_SALT", "email-verify"),
    )

Verification:

python
from itsdangerous import BadSignature, SignatureExpired

def verify_token(token: str, max_age_seconds: int = 86400):
    return serializer.loads(
        token,
        salt=os.environ.get("EMAIL_VERIFY_SALT", "email-verify"),
        max_age=max_age_seconds,
    )

If you need strict single-use semantics or revocation, use a database-backed token table and store only a hash.

4) Build the public verification URL

Do not use:

  • localhost
  • container hostnames
  • internal service DNS names
  • HTTP in production

Use the real public app domain:

python
APP_BASE_URL = os.environ["APP_BASE_URL"]
verify_url = f"{APP_BASE_URL}/auth/verify-email?token={token}"

Example production values:

bash
APP_BASE_URL=https://app.example.com

If you use a frontend SPA:

  • email can point to /verify-email?token=... on the frontend
  • frontend sends token to backend verification endpoint
  • frontend renders verified, expired, or invalid

5) Send the email

Send mail asynchronously if possible so signup is not blocked on SMTP or API latency.

Example send function:

python
def send_verification_email(email: str):
    email = normalize_email(email)
    token = generate_verification_token(email)
    verify_url = f"{APP_BASE_URL}/auth/verify-email?token={token}"

    subject = "Verify your email"
    text_body = f"Click to verify your email: {verify_url}"
    html_body = f"""
    <p>Verify your email address.</p>
    <p><a href="{verify_url}">Verify email</a></p>
    <p>If the button fails, copy this URL:</p>
    <p>{verify_url}</p>
    """

    # enqueue_email_job(to=email, subject=subject, text=text_body, html=html_body)

Production details:

  • configure SPF, DKIM, DMARC
  • store provider message IDs
  • log accepted/deferred/bounced events
  • include raw URL in the email body for clients that break buttons

6) Create the verification endpoint

The endpoint should:

  • accept token
  • validate signature and expiry
  • normalize email
  • look up user
  • mark verified if not already verified
  • be idempotent

Example:

python
from datetime import datetime, timezone
from itsdangerous import BadSignature, SignatureExpired

def confirm_email(token: str, db):
    try:
        data = verify_token(token)
    except SignatureExpired:
        return {"ok": False, "error": "expired"}
    except BadSignature:
        return {"ok": False, "error": "invalid"}

    email = normalize_email(data["email"])
    user = db.find_user_by_email(email)

    if not user:
        return {"ok": False, "error": "not_found"}

    if user["is_email_verified"]:
        return {"ok": True, "status": "already_verified"}

    db.update_user(email, {
        "is_email_verified": True,
        "email_verified_at": datetime.now(timezone.utc),
    })

    return {"ok": True, "status": "verified"}

Idempotency matters. Repeated clicks should not fail if the account is already verified.

7) Add resend verification

Recommended endpoint:

python
@app.post("/auth/resend-verification")
def resend_verification(current_user=Depends(get_current_user)):
    if current_user["is_email_verified"]:
        return {"ok": True, "status": "already_verified"}

    send_verification_email(current_user["email"])
    return {"ok": True, "status": "sent"}

Add rate limiting by:

  • user ID
  • email
  • IP address

Example policy:

  • 3 requests per 15 minutes per user
  • 10 requests per hour per IP

If endpoint is public, return a generic success response to avoid email enumeration.

8) Gate access until verified

You do not need to block login entirely. For small SaaS, a common pattern is:

Allowed for unverified users:

  • login
  • logout
  • verify email
  • resend verification
  • account settings
  • support/contact

Blocked until verified:

  • billing changes
  • subscription checkout
  • API key creation
  • team invites
  • destructive account actions

Example dependency:

python
from fastapi import HTTPException

def require_verified_email(user):
    if not user["is_email_verified"]:
        raise HTTPException(status_code=403, detail="Email verification required")

If billing is gated behind verification, this should align with your payment flow. See Stripe Subscription Setup (Step-by-Step).

9) Refresh user/session state after verification

Common issue: database updates correctly, but UI still shows unverified status.

After verification:

  • refresh the current user object from DB
  • rotate or refresh session if needed
  • reissue auth token if claims include verification state

Example:

python
def after_verify_login_refresh(user_id, db, session):
    fresh_user = db.find_user_by_id(user_id)
    session["is_email_verified"] = fresh_user["is_email_verified"]
    return fresh_user

10) Handle email changes safely

If user changes email:

  • reset is_email_verified=false
  • clear email_verified_at
  • send a new verification email
  • invalidate old verification state for prior pending email

Do not keep old verification status if the primary email changes.

11) Log outcomes for support and debugging

Log these events:

  • verification email sent
  • provider accepted message
  • provider deferred/bounced message
  • invalid token
  • expired token
  • already verified
  • verified successfully
  • resend requested
  • resend rate limited
unverified
verified

Process Flow

Common causes

  • Verification link points to localhost, wrong domain, or HTTP instead of HTTPS.
  • Token signing secret differs between app instances or changed after the email was sent.
  • Email addresses are not normalized consistently, causing user lookup failures.
  • Token expires too quickly or server time is incorrect.
  • Mail provider accepted the request but the email landed in spam or was rejected downstream.
  • Reverse proxy or frontend strips query parameters, so token never reaches the backend.
  • Verification endpoint marks the user verified but the session cache still shows old user state.
  • Resend endpoint has no rate limit and gets abused, causing provider throttling.
  • Background worker is down, so verification emails are never actually sent.

Debugging tips

Start with environment and token checks:

bash
printenv | grep -E 'APP_BASE_URL|SECRET_KEY|EMAIL|SMTP|MAIL|VERIFY'

Generate a test token using current production-like settings:

bash
python - <<'PY'
from itsdangerous import URLSafeTimedSerializer
import os
s = URLSafeTimedSerializer(os.environ['SECRET_KEY'])
print(s.dumps({'email':'test@example.com'}, salt=os.environ.get('EMAIL_VERIFY_SALT','email-verify')))
PY

Decode a real token:

bash
python - <<'PY'
from itsdangerous import URLSafeTimedSerializer
import os
token = 'PASTE_TOKEN_HERE'
s = URLSafeTimedSerializer(os.environ['SECRET_KEY'])
print(s.loads(token, salt=os.environ.get('EMAIL_VERIFY_SALT','email-verify'), max_age=86400))
PY

Hit the live endpoint directly:

bash
curl -i "https://your-app.example.com/auth/verify-email?token=PASTE_TOKEN_HERE"

Check worker process:

bash
ps aux | grep -E 'celery|rq|worker'

Check app logs:

bash
journalctl -u your-app.service -n 200 --no-pager

Check reverse proxy logs:

bash
journalctl -u nginx -n 200 --no-pager

Search code paths:

bash
grep -R "verify-email\|resend-verification\|is_email_verified" .

Additional checks:

  • Confirm the user row exists and is_email_verified is still false before testing the link.
  • Print or log the exact verification URL generated by the app in non-production environments.
  • Decode and validate the token locally with the same secret and salt used in production.
  • Check APP_BASE_URL, SECRET_KEY, and email verification salt on every running instance.
  • Open the email link in a browser and verify the token query string arrives at the app unchanged.
  • Inspect provider logs for accepted, deferred, bounced, or blocked email events.
  • After successful verification, refresh the session or re-fetch the current user from the database.
  • If using CDN or frontend routing, verify it forwards the token to the backend callback endpoint.

If verification fails because of broader auth setup issues, check Common Auth Bugs and Fixes and Password Reset Flow Implementation.

Checklist

  • User model has is_email_verified and email_verified_at.
  • Registration creates unverified users by default.
  • Verification tokens are signed and expire.
  • Verification endpoint is idempotent.
  • Resend endpoint exists and is rate limited.
  • Protected routes enforce verified email where required.
  • APP_BASE_URL uses the correct public domain.
  • SMTP or email API credentials are configured per environment.
  • Email sending failures are logged.
  • Delivery, bounce, and spam issues can be inspected in provider logs.
  • Session or user cache refreshes after verification.
  • Frontend shows clear states for verified, invalid, and expired links.
  • SPF, DKIM, and DMARC are configured for the sending domain.
  • Background worker health is monitored if email is queued.
  • Email change flow resets verification status.
  • Environment values match across all app instances.
  • Verification requirements are included in your launch readiness checks.

For final validation before launch, use the SaaS Production Checklist.

FAQ

Should I store verification tokens in the database?

Only if you need single-use behavior, revocation, or auditability. For many MVPs, signed expiring tokens are simpler and sufficient.

Can unverified users log in?

Yes, but restrict sensitive actions until is_email_verified is true. Keep logout and resend verification available.

What should happen when a user changes their email?

Mark the account unverified for the new address, send a new verification email, and invalidate any prior verification state tied to the old address.

How do I prevent abuse of the resend endpoint?

Require authentication when possible, add per-IP and per-user rate limits, and return generic responses on public endpoints.

Why does verification work locally but fail in production?

Usually because APP_BASE_URL, HTTPS, reverse proxy forwarding, email provider settings, or SECRET_KEY values differ between environments.

Should verification happen before first login?

Either works. For small SaaS, allow login but block sensitive features until verified.

Should tokens be single-use?

Prefer single-use if you store hashed tokens in the database. Signed stateless tokens are simpler but are usually reusable until expiry unless you add revocation logic.

How long should a verification token last?

15 minutes to 24 hours is common. 24 hours is usually fine for MVPs.

Can I resend verification emails safely?

Yes, but rate limit by IP, user, and email and return generic responses where needed.

Should changing email require re-verification?

Yes. Reset is_email_verified to false until the new address is confirmed.

Final takeaway

A good email verification flow is simple: create unverified users, send signed expiring links, verify idempotently, and gate access until confirmed.

Most failures come from bad environment config, broken callback URLs, inconsistent email normalization, or missing delivery visibility.

Keep the flow observable, rate limited, and environment-specific so it works the same in staging and production.