Use case

Email verification

Confirm a new user actually owns the address they signed up with. Single-use tokens, short TTLs, prefetch-safe click handlers, and the deliverability rules that get verification mail into the inbox.

Email verification is the first transactional email a new user receives — and the first signal to ISPs that your sender behaves well. Get the implementation right and you set the tone for every subsequent message. Get it wrong and either you cannot deliver future mail (because you trusted unverified addresses) or you frustrate legitimate users (because your verification link silently dies on the first prefetch hit). The pattern below is the same single-use-token shape used by password reset and magic links, with a few verification-specific wrinkles around partial sign-up states.

Anatomy

What every solid implementation has

  • 1Sign-up endpoint creates the user with email_verified=false; nothing functional is gated yet, but premium / data-bearing actions require verification.
  • 2A token { hash, user_id, expires_at } is persisted with a 24-hour TTL (longer than reset / magic-link because verification has lower urgency).
  • 3A verification email goes out immediately, with a clear plaintext alternative and a click-to-confirm link.
  • 4The GET click-through renders a "confirm" page; the POST consumes the token, flips email_verified=true, deletes the token.
  • 5A re-send endpoint replaces any in-flight token with a fresh one — invalidates the old to prevent accumulation.
  • 6Hard bounces flip the user to a "bounce" state so subsequent re-verification attempts surface the address-is-wrong UX, not silently retry.

Implementation

The actual code

  1. Issue + send the verification email

    Idempotency key bucketed by user and signup hour so a refresh during signup does not double-send. The send is async / fire-and-forget — sign-up should not block on the email upstream.

    import { Sndr } from '@rkiza/sndr';
    import { randomBytes, createHash } from 'node:crypto';
    
    const sndr = new Sndr(process.env.SNDR_API_KEY!);
    
    export async function sendVerificationEmail(userId: string) {
      const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
      if (user.email_verified) return; // No-op for already-verified users.
    
      // Replace any in-flight token so the user always has at most one active.
      await db.verificationToken.deleteMany({ where: { user_id: userId } });
    
      const raw = randomBytes(32).toString('base64url');
      const hashed = createHash('sha256').update(raw).digest('hex');
      await db.verificationToken.create({
        data: {
          user_id: userId,
          token_hash: hashed,
          expires_at: new Date(Date.now() + 24 * 60 * 60_000),
        },
      });
    
      const link = `https://yourdomain.com/auth/verify?token=${raw}`;
      const hourBucket = Math.floor(Date.now() / 3_600_000);
    
      await sndr.emails.send(
        {
          from: 'auth@yourdomain.com',
          to: user.email,
          subject: 'Confirm your email address',
          text: `Confirm your email address by clicking:\n\n${link}\n\nLink expires in 24 hours.`,
          html: `<p>Confirm your email by clicking <a href="${link}">here</a>.</p><p>Link expires in 24 hours.</p>`,
        },
        { idempotencyKey: `email-verify-${userId}-${hourBucket}` },
      );
    }
  2. Two-step click-through (prefetch-safe)

    Outlook Safe Links, Slack, iMessage will hit your GET URL before the user clicks. Render a confirmation page on GET; consume the token only on a POST from the form. Atomic update + delete on success.

    import { createHash } from 'node:crypto';
    
    // GET — render the confirmation page. Do not mutate state.
    export async function showVerificationConfirmation(token: string) {
      if (!token) return { status: 404 };
      // Render: <form method="POST" action="/auth/verify/confirm">
      //   <input type="hidden" name="token" value="..." />
      //   <button>Confirm my email</button>
      // </form>
      return { status: 200 };
    }
    
    // POST — atomic compare-and-consume.
    export async function consumeVerificationToken(token: string) {
      const hashed = createHash('sha256').update(token).digest('hex');
      const row = await db.verificationToken.findUnique({ where: { token_hash: hashed } });
      if (!row || row.expires_at < new Date()) {
        throw new Error('invalid_or_expired_token');
      }
    
      await db.$transaction([
        db.user.update({
          where: { id: row.user_id },
          data: { email_verified: true, email_verified_at: new Date() },
        }),
        db.verificationToken.delete({ where: { id: row.id } }),
      ]);
      return { user_id: row.user_id };
    }

Production considerations

What you have to get right

Verification != gating everything

Gate the actions that genuinely depend on email ownership (changing the address itself, password recovery, billing receipts going to the right place). Locking the whole product behind verification creates a brittle UX and a support cost without buying much security.

Two-step click-through

Email-client prefetchers will hit your URL. A GET that auto-confirms means a prefetch consumes the token; the real click then fails. Always render a confirmation page; only POST consumes.

Re-send safely

A "resend verification email" button should replace any in-flight token, not append. Otherwise you build up a stack of valid tokens, lengthening the replay window for any leaked email.

Hard bounce → fix-it UX

If the verification email hard-bounces, the address is bad. Surface a 'we couldn't reach your email — please update it' flow rather than retrying. SNDR webhooks let you detect this in seconds.

No enumeration on re-send

If the user requests a re-send for an address you do not have, respond identically to a successful resend. Otherwise you have a free enumeration endpoint.

TTL longer than reset / magic link

24 hours is the usual default — verification has lower urgency than auth flows, and a user who got the email overnight should be able to click it the next morning.

FAQ

Should I require verification before sign-up completes?

Soft verification (sign-up completes, but mail-dependent features wait) is the dominant pattern. Hard verification (no sign-up without click) makes the first impression brittle — a single email-prefetch issue and your funnel drops.

What if the user enters the wrong email?

Provide a 'change email address' option on the verification screen. They can correct typos without restarting sign-up. The flow re-issues a token to the new address (and invalidates the old).

How does this differ from a magic link?

Magic links sign the user in. Verification confirms the address is real but does not produce a session by itself (the user is already signed in via password or social). Mechanically the same single-use-token pattern; semantically different.

Do I need to verify SSO / OAuth users?

Usually not — the OAuth provider already verified the email. Mark email_verified=true on Google / GitHub / Apple sign-ins and skip the verification email entirely.

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.