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
# 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:
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:
# 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.
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_verifiedboolean defaultfalseemail_verified_atnullable timestamp
Example 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:
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:
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.
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:
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:
APP_BASE_URL = os.environ["APP_BASE_URL"]
verify_url = f"{APP_BASE_URL}/auth/verify-email?token={token}"Example production values:
APP_BASE_URL=https://app.example.comIf 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, orinvalid
5) Send the email
Send mail asynchronously if possible so signup is not blocked on SMTP or API latency.
Example send function:
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:
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:
@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
- 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:
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:
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_user10) 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
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:
printenv | grep -E 'APP_BASE_URL|SECRET_KEY|EMAIL|SMTP|MAIL|VERIFY'Generate a test token using current production-like settings:
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')))
PYDecode a real token:
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))
PYHit the live endpoint directly:
curl -i "https://your-app.example.com/auth/verify-email?token=PASTE_TOKEN_HERE"Check worker process:
ps aux | grep -E 'celery|rq|worker'Check app logs:
journalctl -u your-app.service -n 200 --no-pagerCheck reverse proxy logs:
journalctl -u nginx -n 200 --no-pagerSearch code paths:
grep -R "verify-email\|resend-verification\|is_email_verified" .Additional checks:
- Confirm the user row exists and
is_email_verifiedis 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_verifiedandemail_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_URLuses 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.