# Email Verification Low-Level Design: Token Generation, Hash Storage, and Expiry Handling

Source: https://www.techinterview.org/post/3233468297/lld-email-verification/
Updated: 2026-04-17 · techinterview.org

Email verification confirms a user controls the email address they registered with, blocking fake accounts, disposable addresses, and typo'd emails that lead to lost users. Core design: generate a cryptographically random token, store its hash, send the link, handle expiry and resend requests, and securely complete verification in a single database update. Getting this wrong leads to user lockout, token enumeration, or dangling unverified accounts cluttering the database.

## Core Data Model


```
CREATE TABLE User (
    user_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email          TEXT NOT NULL UNIQUE,
    email_verified BOOLEAN NOT NULL DEFAULT FALSE,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE EmailVerification (
    verification_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL REFERENCES User(user_id) ON DELETE CASCADE,
    email           TEXT NOT NULL,          -- the email being verified
    token_hash      CHAR(64) NOT NULL,      -- SHA-256 of the raw token (NEVER store raw)
    expires_at      TIMESTAMPTZ NOT NULL,
    verified_at     TIMESTAMPTZ,            -- NULL until verified
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_verification_user ON EmailVerification (user_id, created_at DESC);
CREATE INDEX idx_verification_token ON EmailVerification (token_hash) WHERE verified_at IS NULL;

-- Rate limiting: track resend requests per user per hour
CREATE TABLE VerificationResend (
    user_id     UUID NOT NULL,
    sent_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ip_address  INET
);
CREATE INDEX idx_resend_user_time ON VerificationResend (user_id, sent_at DESC);
```


## Generating and Sending the Verification Token


```
import secrets, hashlib
from datetime import datetime, timezone, timedelta
import psycopg2

TOKEN_BYTES = 32           # 256 bits of entropy
EXPIRY_HOURS = 24
MAX_RESENDS_PER_HOUR = 3

def send_verification_email(conn, user_id: str, email: str, ip_address: str) -> str:
    """
    Generate a verification token, store its hash, and send the email.
    Rate-limited to MAX_RESENDS_PER_HOUR per user.
    Returns the verification_id (for testing/logging).
    """
    # Rate limit check
    with conn.cursor() as cur:
        cur.execute("""
            SELECT COUNT(*) FROM VerificationResend
            WHERE user_id = %s AND sent_at >= NOW() - interval '1 hour'
        """, (user_id,))
        count = cur.fetchone()[0]
    if count >= MAX_RESENDS_PER_HOUR:
        raise ValueError("Too many verification emails sent. Try again later.")

    # Generate raw token (never stored)
    raw_token = secrets.token_urlsafe(TOKEN_BYTES)  # ~43 URL-safe chars
    token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
    expires_at = datetime.now(timezone.utc) + timedelta(hours=EXPIRY_HOURS)

    with conn.cursor() as cur:
        # Invalidate any existing pending tokens for this user+email
        cur.execute("""
            UPDATE EmailVerification
            SET expires_at = NOW()
            WHERE user_id = %s AND email = %s AND verified_at IS NULL
        """, (user_id, email))

        # Insert new token
        cur.execute("""
            INSERT INTO EmailVerification (user_id, email, token_hash, expires_at)
            VALUES (%s, %s, %s, %s)
            RETURNING verification_id
        """, (user_id, email, token_hash, expires_at))
        verification_id = cur.fetchone()[0]

        # Log resend for rate limiting
        cur.execute(
            "INSERT INTO VerificationResend (user_id, ip_address) VALUES (%s, %s)",
            (user_id, ip_address)
        )
    conn.commit()

    # Build verification URL — token in query param (not path) for cleaner logs
    verify_url = f"https://app.example.com/verify-email?token={raw_token}"
    send_email(
        to=email,
        subject="Verify your email address",
        body=f"Click this link to verify your email (valid for {EXPIRY_HOURS} hours):n{verify_url}nnIf you didn't request this, ignore this email."
    )
    return str(verification_id)
```


## Verifying the Token


```
def verify_email(conn, raw_token: str) -> dict:
    """
    Verify an email address using the token from the link.
    Returns {"status": "ok", "email": ...} or raises ValueError.
    """
    # Hash the incoming token to look it up
    token_hash = hashlib.sha256(raw_token.encode()).hexdigest()

    with conn.cursor() as cur:
        cur.execute("""
            SELECT verification_id, user_id, email, expires_at, verified_at
            FROM EmailVerification
            WHERE token_hash = %s
        """, (token_hash,))
        row = cur.fetchone()

    if not row:
        raise ValueError("Invalid verification link")
    verification_id, user_id, email, expires_at, verified_at = row

    if verified_at is not None:
        return {"status": "already_verified", "email": email}

    if datetime.now(timezone.utc) > expires_at:
        raise ValueError("Verification link has expired. Request a new one.")

    # Mark as verified — both EmailVerification and User in same transaction
    with conn.cursor() as cur:
        cur.execute(
            "UPDATE EmailVerification SET verified_at = NOW() WHERE verification_id = %s",
            (verification_id,)
        )
        cur.execute(
            "UPDATE User SET email_verified = TRUE WHERE user_id = %s",
            (user_id,)
        )
    conn.commit()

    return {"status": "ok", "email": email, "user_id": str(user_id)}
```


## Edge Cases and Security Hardening


```
# Token enumeration: the token hash lookup returns the same "Invalid" error
# for both nonexistent and expired tokens — don't reveal which.

# Timing attack on hash comparison:
# Use hmac.compare_digest instead of == for token comparison if doing
# application-level comparison (not needed here since we hash and use DB lookup).

import hmac

def safe_token_verify(stored_hash: str, input_token: str) -> bool:
    """Use constant-time comparison to prevent timing oracle attacks."""
    input_hash = hashlib.sha256(input_token.encode()).hexdigest()
    return hmac.compare_digest(stored_hash, input_hash)

# Cleanup job: remove expired unverified tokens older than 30 days
def cleanup_expired_verifications(conn):
    with conn.cursor() as cur:
        cur.execute("""
            DELETE FROM EmailVerification
            WHERE verified_at IS NULL
              AND expires_at < NOW() - interval '30 days'
        """)
        deleted = cur.rowcount
    conn.commit()
    return deleted

# Prevent unverified users from accessing protected resources
def require_verified_email(user_id: str, conn):
    with conn.cursor() as cur:
        cur.execute("SELECT email_verified FROM User WHERE user_id = %s", (user_id,))
        row = cur.fetchone()
    if not row or not row[0]:
        raise PermissionError("Email verification required")
```


## Key Interview Points

- **Store hash, send raw**: If the database is breached, token hashes cannot be reversed to valid tokens. An attacker with the hash cannot verify email addresses or take over accounts. SHA-256 is sufficient here (unlike passwords) because tokens have 256 bits of entropy — brute-force is infeasible even without bcrypt's slowness. Passwords need slow hashes because they have low entropy.

- **Token invalidation on resend**: When a user requests a new verification link, expire all previous pending tokens for that email. This prevents confusion from multiple valid links floating around and reduces the attack window for token theft.

- **URL token placement**: Tokens in query params (?token=...) appear in server access logs and browser history. Tokens in path segments (/verify/TOKEN) also appear in logs. Mitigation: (1) HTTPS (tokens are encrypted in transit); (2) short expiry (24h); (3) single-use (invalidated on first use). Some systems POST the token from a landing page to avoid Referer header leakage.

- **Email change verification**: When a user changes their email address, require verification of the new address before updating the primary email. Sequence: (1) store new_email and new_email_token on the User row; (2) send verification to new_email; (3) only update email and clear new_email_token on successful verification. This prevents account hijacking via email change.

- **Rate limiting layers**: Per-user rate limit (MAX_RESENDS_PER_HOUR) prevents spamming a victim's inbox. IP-level rate limit catches coordinated attacks across multiple accounts. Add CAPTCHA after 2 failed verifications. Block known disposable email domains (mailinator.com, guerrillamail.com) at registration time using a maintained blocklist.

Email verification and account security system design is discussed in [Google system design interview questions](https://www.techinterview.org/post/3233460274/netflix-interview-guide-2026-streaming-architecture-recommendation-systems-and-engineering-excellence/).

Email verification and account confirmation design is covered in [Coinbase system design interview preparation](https://www.techinterview.org/post/3233459920/coinbase-interview/).

Email verification and user onboarding security design is discussed in [LinkedIn system design interview guide](https://www.techinterview.org/post/3233460278/linkedin-interview-guide-2026-social-graph-engineering-feed-ranking-and-professional-network-scale/).
