Email verification codes

Send a six-digit code at sign-up or login, and check it safely.

A one-time code is the most common transactional email there is: someone types an address, you prove it is theirs. Rovela does the delivery half — DKIM signing, retries, bounce handling, and an event trail for every message. The other half, making and checking the code, lives in your application, and that is where the mistakes usually are. This page covers both.

The whole flow

  • Generate a code with a cryptographic random generator.
  • Store a hash of it, an expiry and an attempt counter — never the code itself.
  • Send it with POST /emails, with both html and text.
  • On submit, compare in constant time, count the attempt, and delete on success.

A sending_access key is enough for all of it, and it is the right one: the server that sends codes has no reason to be able to manage domains.

The send, on its own

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: verify-8f3a2c71" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "alice@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is</p><p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">482913</p><p>It expires in 10 minutes. If you did not ask for it, you can ignore this email.</p>",
    "text": "Your verification code is 482913. It expires in 10 minutes. If you did not ask for it, you can ignore this email."
  }'

The response is the message id, which you can look up in the dashboard later:

{"object": "email", "id": "9f1c2e40-..."}

Storing the code

One row per address. The code itself is never written down — only a keyed hash:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,           -- lower-cased before it gets here
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Use an HMAC with a server-side secret, not a bare SHA-256. Six digits are only a million possibilities, so an unkeyed hash from a leaked table is reversed in well under a second. The secret lives in an environment variable, not in the database.

Node.js

Uses the built-in fetch and crypto, and pg for PostgreSQL. Any framework works the same way; these are the two functions your sign-up and login handlers call.

import crypto from "node:crypto"
import pg from "pg"

const db = new pg.Pool()
const PEPPER = process.env.CODE_PEPPER      // long random secret
const API_KEY = process.env.ROVELA_API_KEY  // sending_access is enough

function hashCode(email, code) {
  return crypto.createHmac("sha256", PEPPER).update(email + ":" + code).digest("hex")
}

export async function sendCode(rawEmail) {
  const email = rawEmail.trim().toLowerCase()

  // One code a minute per address, so the button cannot be used to flood
  // someone's inbox -- or to burn through your sending quota.
  const recent = await db.query(
    "SELECT 1 FROM verification_codes WHERE email = $1 AND sent_at > NOW() - INTERVAL '60 seconds'",
    [email]
  )
  if (recent.rowCount > 0) return { ok: false, error: "too_soon" }

  // randomInt uses the OS generator. Math.random() is not for secrets.
  const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, "0")

  // Replacing the row also invalidates any earlier code for this address.
  await db.query(
    `INSERT INTO verification_codes (email, code_hash, expires_at, attempts, sent_at)
     VALUES ($1, $2, NOW() + INTERVAL '10 minutes', 0, NOW())
     ON CONFLICT (email) DO UPDATE
       SET code_hash = EXCLUDED.code_hash, expires_at = EXCLUDED.expires_at,
           attempts = 0, sent_at = NOW()`,
    [email, hashCode(email, code)]
  )

  const res = await sendWithRetry({
    from: "Acme <security@mail.acme.com>",
    to: email,
    subject: "Your Acme code: " + code,
    html:
      '<p>Your verification code is</p>' +
      '<p style="font-size:28px;font-weight:600;letter-spacing:4px">' + code + '</p>' +
      '<p>It expires in 10 minutes. If you did not ask for it, ignore this email.</p>',
    text: "Your verification code is " + code + ". It expires in 10 minutes.",
  })

  if (res.ok) return { ok: true }
  const body = await res.json().catch(() => ({}))
  if (res.status === 400 && String(body.message).startsWith("recipient is suppressed")) {
    // This address bounced or marked you as spam before. Telling the user
    // "check your inbox" would be a lie -- ask for a different address.
    return { ok: false, error: "undeliverable" }
  }
  throw new Error("send failed: " + res.status + " " + body.message)
}

// One Idempotency-Key for every attempt of the same send: if the first
// request reached us and only the response was lost, the retry returns the
// original result instead of sending a second code.
async function sendWithRetry(payload) {
  const key = "verify-" + crypto.randomUUID()
  for (let attempt = 1; ; attempt++) {
    try {
      const res = await fetch("https://api.rovela.dev/emails", {
        method: "POST",
        headers: {
          Authorization: "Bearer " + API_KEY,
          "Content-Type": "application/json",
          "Idempotency-Key": key,
        },
        body: JSON.stringify(payload),
      })
      const retryable = res.status === 429 || res.status >= 500
      if (!retryable || attempt === 3) return res
      const wait = Number(res.headers.get("retry-after") ?? 1)
      await new Promise((r) => setTimeout(r, wait * 1000))
    } catch (err) {
      if (attempt === 3) throw err   // network error: same key, try again
    }
  }
}

export async function verifyCode(rawEmail, submitted) {
  const email = rawEmail.trim().toLowerCase()

  // Claim the attempt and read the hash in one statement. Reading first and
  // incrementing after would let fifty parallel guesses all pass the
  // "fewer than five attempts" check before any of them is counted.
  const { rows } = await db.query(
    `UPDATE verification_codes SET attempts = attempts + 1
     WHERE email = $1 AND attempts < 5 AND expires_at > NOW()
     RETURNING code_hash`,
    [email]
  )
  if (rows.length === 0) return false   // no code, expired, or out of attempts

  const expected = Buffer.from(rows[0].code_hash, "hex")
  const actual = Buffer.from(hashCode(email, String(submitted).trim()), "hex")
  if (!crypto.timingSafeEqual(expected, actual)) return false

  // Single use: a correct code that still works afterwards is a password.
  await db.query("DELETE FROM verification_codes WHERE email = $1", [email])
  return true
}

Python

The same logic with requests. The storage calls are left as comments; they are the same three statements as above.

import hashlib, hmac, os, secrets, uuid
import requests

PEPPER = os.environ["CODE_PEPPER"].encode()
API_KEY = os.environ["ROVELA_API_KEY"]

def hash_code(email: str, code: str) -> str:
    return hmac.new(PEPPER, f"{email}:{code}".encode(), hashlib.sha256).hexdigest()

def send_code(email: str) -> None:
    email = email.strip().lower()
    code = f"{secrets.randbelow(1_000_000):06d}"   # not random.randint
    # db: upsert (email, hash_code(email, code), now + 10 min, attempts = 0)

    resp = requests.post(
        "https://api.rovela.dev/emails",
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Idempotency-Key": f"verify-{uuid.uuid4()}",
        },
        json={
            "from": "Acme <security@mail.acme.com>",
            "to": email,
            "subject": f"Your Acme code: {code}",
            "html": f"<p>Your verification code is <strong>{code}</strong>.</p>"
                    "<p>It expires in 10 minutes.</p>",
            "text": f"Your verification code is {code}. It expires in 10 minutes.",
        },
        timeout=10,
    )
    resp.raise_for_status()

def verify_code(email: str, submitted: str) -> bool:
    email = email.strip().lower()
    # db: UPDATE ... SET attempts = attempts + 1
    #     WHERE email = %s AND attempts < 5 AND expires_at > NOW()
    #     RETURNING code_hash
    stored_hash = ...
    if stored_hash is None:
        return False
    if not hmac.compare_digest(stored_hash, hash_code(email, submitted.strip())):
        return False
    # db: DELETE FROM verification_codes WHERE email = %s
    return True

When the send does not go through

  • 400 recipient is suppressed: … — the address hard-bounced or complained earlier and is on your suppression list. Ask for another address rather than showing "we sent you a code".
  • 400 naming your domain — the from domain is not verified in this organization. A configuration problem, not a user one; alert yourself.
  • 429 — a rate limit or your plan's quota. It carries Retry-After; the retry loop above honours it.

A 200 means the message was accepted, not delivered. If users say the code never arrived, the message page in the dashboard shows each step, and an email.bounced webhook tells you without anyone having to ask — see Handling bounces.

Details that matter

  • Code in the subject. It lets people read it from a notification without opening the mail. Leave it out if your users share screens or devices often.
  • Same answer either way. On a login form, respond identically whether or not the address has an account, or the form becomes a way to test which addresses are your customers.
  • A subdomain sender. Sending from mail.acme.com keeps these messages' reputation apart from your marketing mail — codes are the mail you can least afford to see in spam.

Related

A link instead of a code: Password reset links. Every field of the send: Emails API.