Email verification
6-digit OTP delivered via Resend. SHA-256 hashed storage, timing-safe comparison, and a 10-minute expiry.
Flow
- Registration completes - user record created with emailVerified: false
- generateOTP() produces a 6-digit code via crypto.randomInt (CSPRNG - not Math.random)
- hashOTP() hashes it with SHA-256 - plaintext OTP is never stored
- OTP record saved with: email, hashedOTP, purpose: 'email-verification', expiresAt: now + 10 minutes, used: false
- sendVerificationEmail() delivers the plaintext OTP via Resend
- User redirected to /verify-email
- User submits the 6-digit code
- verifyEmailAction() looks up: { email, purpose: 'email-verification', used: false, expiresAt > now }
- verifyOTP() runs crypto.timingSafeEqual - prevents timing attacks
- OTP marked used. user.emailVerified = true.
- 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
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.