Use case

OTP and 2FA email

Send email verification codes through SNDR. Your application generates the code, enforces its expiry and attempt limit, and verifies it exactly once.

SNDR delivers email; it does not provide a managed OTP verification or SMS API. The PostgreSQL examples below use an application-owned connection pool, a separate OTP_HASH_SECRET, and a row lock for single-use verification. Add per-address and per-IP rate limiting before issuing codes and before verifying them. Use a dedicated challenge purpose so sign-in codes cannot authorize unrelated actions.

Anatomy

What every solid implementation has

  • 1Six-digit numeric code (10^6 = ~20 bits entropy) — strikes the balance between typability and resistance to online guessing when paired with throttling.
  • 2An application-owned record with a challenge ID, keyed code hash, expiry, and attempt count — never store the raw code.
  • 3A 10-minute TTL by default; configurable per environment.
  • 4A send call with the code in plaintext for the body and a clear, scannable HTML version.
  • 5Per-address rate limiting (e.g. 3 attempts per 10 min) AND per-IP throttling on the request endpoint.
  • 6A transaction with a row lock serializes verification, commits failed attempt counts, and consumes a successful challenge once.
  • 7A clear subject and body explaining the code purpose and expiry.

Implementation

The actual code

  1. Create the application-owned challenge table

    This example uses PostgreSQL and a pg.Pool exported by your application. SNDR stores neither the challenge nor its verification state.

    -- Example for PostgreSQL. The application supplies a pg.Pool as ./db.
    CREATE TABLE login_otp (
      email text PRIMARY KEY,
      challenge_id uuid NOT NULL,
      code_hash text NOT NULL,
      expires_at timestamptz NOT NULL,
      attempt_count integer NOT NULL DEFAULT 0
    );
    -- Provision OTP_HASH_SECRET with at least 32 random bytes in the app's
    -- secret store. Keep it separate from this database and the SNDR API key.
    -- Schedule cleanup of expired rows in your application.
  2. Issue a code and send the email

    The application must validate the address, throttle requests, and keep the HMAC secret separate from the database. A new issue replaces the previous challenge for the address.

    import { Sndr } from '@rkiza/sndr';
    import { createHmac, randomInt, randomUUID } from 'node:crypto';
    import { pool } from './db'; // Your application's PostgreSQL connection pool.
    
    const sndr = new Sndr(process.env.SNDR_API_KEY!);
    const hashSecret = process.env.OTP_HASH_SECRET;
    if (!hashSecret || Buffer.byteLength(hashSecret) < 32) {
      throw new Error('Configure a separate random OTP_HASH_SECRET');
    }
    
    export function hashCode(challengeId: string, email: string, code: string) {
      // A plain SHA-256 hash of six digits can be brute-forced offline.
      return createHmac('sha256', hashSecret!)
        .update(JSON.stringify([challengeId, email, code])).digest('hex');
    }
    
    export async function sendOtp(email: string) {
      // Validate/normalize email using your app's account policy first.
      // Your request handler MUST enforce per-address and per-IP send limits.
      const challengeId = randomUUID();
      const code = String(randomInt(0, 1_000_000)).padStart(6, '0');
      await pool.query(
        `INSERT INTO login_otp (email, challenge_id, code_hash, expires_at)
         VALUES ($1, $2, $3, NOW() + INTERVAL '10 minutes')
         ON CONFLICT (email) DO UPDATE SET challenge_id = EXCLUDED.challenge_id,
           code_hash = EXCLUDED.code_hash, expires_at = EXCLUDED.expires_at,
           attempt_count = 0`,
        [email, challengeId, hashCode(challengeId, email, code)],
      );
      await sndr.emails.send({
        from: 'Your App <auth@yourdomain.com>',
        to: email,
        subject: 'Your sign-in code',
        text: `Your sign-in code is ${code}. It expires in 10 minutes. If you did not request this, ignore this email.`,
      }, { idempotencyKey: `login-otp-${challengeId}` });
      // Keep the same challenge ID and key if retrying this send. An accepted
      // response is queued, not a guarantee that the email reached the inbox.
      return { challengeId };
    }
  3. Verify and consume exactly once

    A row lock serializes concurrent checks. Failed attempts are committed; a successful challenge is deleted before a second verifier can read it.

    import { timingSafeEqual } from 'node:crypto';
    import { pool } from './db';
    import { hashCode } from './send-otp';
    
    export async function verifyOtp(email: string, challengeId: string, code: string) {
      // Apply per-IP verification limits in the request handler too.
      if (!/^\d{6}$/.test(code)) throw new Error('invalid_code');
      const client = await pool.connect();
      let verified = false;
      try {
        await client.query('BEGIN');
        const { rows } = await client.query(
          'SELECT *, expires_at > NOW() AS fresh FROM login_otp WHERE email = $1 FOR UPDATE',
          [email],
        );
        const row = rows[0];
        if (row && row.fresh && row.attempt_count < 5 && row.challenge_id === challengeId) {
          const stored = Buffer.from(row.code_hash, 'hex');
          const submitted = Buffer.from(hashCode(challengeId, email, code), 'hex');
          verified = stored.length === submitted.length && timingSafeEqual(stored, submitted);
          if (verified) {
            await client.query('DELETE FROM login_otp WHERE email = $1', [email]);
          } else {
            await client.query('UPDATE login_otp SET attempt_count = attempt_count + 1 WHERE email = $1', [email]);
          }
        }
        // Commit failed attempts too; throwing before COMMIT would undo them.
        await client.query('COMMIT');
      } catch (error) {
        await client.query('ROLLBACK');
        throw error;
      } finally {
        client.release();
      }
      if (!verified) throw new Error('invalid_code');
      // Only now may your app create the session for this verified address.
      return { email };
    }

Production considerations

What you have to get right

Code format

Six numeric digits is the proven default. Eight digits buys a small amount of entropy at large UX cost. Alphanumeric codes have higher entropy but lower scan-and-type accuracy.

Rate limiting

3 codes per 10 minutes per address is a reasonable default. Per-IP throttling defends against an attacker spraying codes across many addresses. Expire the challenge after a small number of failed attempts; avoid letting unauthenticated requests permanently lock an account.

Deliverability for auth mail

Monitor delivery latency for authentication email. Use a dedicated subdomain (auth.yourdomain.com), publish strict SPF/DKIM/DMARC, monitor inbox rate per ISP, and never co-mingle with marketing volume on the same IPs.

Subject line discipline

Use a short subject that describes the purpose. Decide whether to expose the code in notification previews based on your application's privacy needs.

Single-use deletion

On successful verify, delete the row (or atomically mark consumed in a single SQL statement). Otherwise a leaked code retains its TTL window for a second use.

No code leakage in URLs

Never put the code in a URL parameter, even for QR-code-style flows. URLs leak into browser history, referer headers, and server logs. Always have the user enter the code in a form.

FAQ

Should I use email OTP or magic links?

Magic links are easier UX (one click vs typing a code) but less compatible with multi-device flows — clicking on a phone but signing in on a desktop is awkward. OTP scales better when the user is on the device they're trying to sign into. Many products offer both.

How do I prevent OTP brute force?

Per-address attempt counter (lock after 5 failures), per-IP throttling on the request endpoint, exponential cool-down on the address after each lock. These three together bound the cost of brute force to far below the entropy budget of a 6-digit code.

Should I send the code via SMS too?

SNDR currently provides email delivery, not an SMS API or phone-code verification service. If your application needs SMS, integrate a separate SMS provider and keep challenge creation, expiry, throttling, and verification in your application.

What about TOTP / authenticator apps for 2FA?

TOTP is strictly better than email-only OTP for the second factor on accounts that already have a password. Email OTP shines as a primary factor for passwordless flows or as the recovery channel for TOTP.

Related

Ship this in production today.

Free tier includes 3,000 emails / month. No card required.

Start building

Your next message starts here.

Connect your domain, create an API key, and send with SNDR.