Email verification

6-digit OTP delivered via Resend. SHA-256 hashed storage, timing-safe comparison, and a 10-minute expiry.

Flow

  1. Registration completes - user record created with emailVerified: false
  2. generateOTP() produces a 6-digit code via crypto.randomInt (CSPRNG - not Math.random)
  3. hashOTP() hashes it with SHA-256 - plaintext OTP is never stored
  4. OTP record saved with: email, hashedOTP, purpose: 'email-verification', expiresAt: now + 10 minutes, used: false
  5. sendVerificationEmail() delivers the plaintext OTP via Resend
  6. User redirected to /verify-email
  7. User submits the 6-digit code
  8. verifyEmailAction() looks up: { email, purpose: 'email-verification', used: false, expiresAt > now }
  9. verifyOTP() runs crypto.timingSafeEqual - prevents timing attacks
  10. OTP marked used. user.emailVerified = true.
  11. Client redirected to /dashboard

OTP security

  • Generated with crypto.randomInt - cryptographically secure, not Math.random
  • SHA-256 hash stored - plaintext never written to the database
  • Verified with crypto.timingSafeEqual - constant-time comparison prevents timing side-channel attacks
  • Expiry checked at query time with expiresAt > now - not just on creation
  • used flag set on successful verification - prevents replay attacks
  • A single OTP record is used and discarded - no reuse possible

The server action

actions/auth.ts
export async function verifyEmailAction(
  formData: FormData
): Promise<ActionState> {
  const raw = {
    email: formData.get('email'),
    otp: formData.get('otp'),
  }

  const sanitized = sanitizeAndStrip(raw)
  const parsed = parseSchema(verifyEmailSchema, sanitized)
  if (!parsed.success) return parsed

  try {
    await connectMongo()

    const record = await OTPModel.findOne({
      email: sanitized.email,
      purpose: 'email-verification',
      used: false,
      expiresAt: { $gt: new Date() },
    })

    if (!record) {
      return { success: false, error: 'Invalid or expired code.' }
    }

    const valid = verifyOTP(sanitized.otp, record.hashedOTP)
    if (!valid) {
      return { success: false, error: 'Invalid or expired code.' }
    }

    record.used = true
    await record.save()

    await UserModel.updateOne(
      { email: sanitized.email },
      { emailVerified: true }
    )

    return { success: true }
  } catch {
    return { success: false, error: 'Verification failed. Please try again.' }
  }
}

Resending the OTP

The VerifyEmailForm includes a resend button that calls resendVerificationOTPAction(). It generates a fresh OTP record and sends a new email. The button is disabled for 60 seconds after each click to prevent abuse.

Expiry and TTL

MongoDB: OTPModel has a TTL index on the expiresAt field with expireAfterSeconds: 0. MongoDB's background TTL monitor deletes expired documents automatically.

Supabase: Expired rows are not auto-deleted but are never returned because every query filters expiresAt > now. An optional pg_cron cleanup job is included in schema.sql.

Firebase: Expiry is checked in-memory after querying - expired documents are filtered server-side. Periodic cleanup can be implemented with a Cloud Function or scheduled job.