OTP Login
A one-time password (OTP) is a short code — typically 6 digits — that the user types into your app after receiving it by email or SMS. Unlike a magic link, the code transfers between devices because the human is the courier.
This guide builds OTP login on top of createHash (to store codes safely) and createRateLimiter (to prevent brute force). The handoff is the same as every other auth flow in this library: await auth().login(user).
import { createAuth, createHash, createRateLimiter,} from 'ideal-auth';
export const auth = createAuth<User>({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: cookieBridge, resolveUser: async (id) => db.user.findUnique({ where: { id } }),});
export const otpHash = createHash();
export const otpRequestLimiter = createRateLimiter({ maxAttempts: 5, windowMs: 15 * 60 * 1000,});
export const otpVerifyLimiter = createRateLimiter({ maxAttempts: 5, windowMs: 15 * 60 * 1000,});Two separate limiters: one for issuing codes (prevent SMS flooding / mailbox spam), one for verifying them (prevent brute force of the 6-digit space).
Database schema
Section titled “Database schema”OTPs are short-lived rows with a hashed code, an expiry, and an attempt counter. Don’t reuse the users table — codes get rotated and deleted constantly.
CREATE TABLE otp_codes ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, purpose TEXT NOT NULL, -- 'login', '2fa', 'verify-email', ... code_hash TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempts INT NOT NULL DEFAULT 0, consumed_at TIMESTAMPTZ);
CREATE INDEX idx_otp_user_purpose ON otp_codes (user_id, purpose, expires_at);Generating a code
Section titled “Generating a code”A 6-digit numeric code has 1,000,000 possibilities. That’s brute-forceable in seconds without rate limiting — which is why the attempts counter matters more than the code length.
import { randomInt } from 'node:crypto';import { otpHash } from './auth';import { db } from './db';
const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutesconst MAX_ATTEMPTS = 5;
export function generateCode(): string { // randomInt is cryptographically secure; Math.random is NOT return randomInt(0, 1_000_000).toString().padStart(6, '0');}
export async function issueOtp(userId: string, purpose: string) { // Invalidate any previous unconsumed codes for the same purpose await db.otpCode.deleteMany({ where: { userId, purpose, consumedAt: null }, });
const code = generateCode(); const codeHash = await otpHash.make(code);
await db.otpCode.create({ data: { id: crypto.randomUUID(), userId, purpose, codeHash, expiresAt: new Date(Date.now() + CODE_TTL_MS), }, });
return code; // return plaintext only to the caller (to send to the user)}The two routes
Section titled “The two routes”1. Request a code
Section titled “1. Request a code”import { otpRequestLimiter } from '@/lib/auth';import { issueOtp } from '@/lib/otp';import { db } from '@/lib/db';import { sendEmail } from '@/lib/mail';import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) { const { email } = await req.json(); const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1';
const ipLimit = await otpRequestLimiter.attempt(`otp:req:ip:${ip}`); if (!ipLimit.allowed) { return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); }
const emailLimit = await otpRequestLimiter.attempt( `otp:req:email:${email.toLowerCase()}`, );
// Generic response — same body whether the user exists or not const ok = NextResponse.json({ message: 'If that email exists, a code was sent.', }); if (!emailLimit.allowed) return ok;
const user = await db.user.findUnique({ where: { email: email.toLowerCase() }, }); if (!user) return ok;
const code = await issueOtp(user.id, 'login');
await sendEmail({ to: user.email, subject: `Your sign-in code: ${code}`, text: `Your code is ${code}. It expires in 10 minutes.`, });
return ok;}2. Verify the code
Section titled “2. Verify the code”import { auth, otpHash, otpVerifyLimiter } from '@/lib/auth';import { db } from '@/lib/db';import { NextRequest, NextResponse } from 'next/server';
const MAX_ATTEMPTS = 5;
export async function POST(req: NextRequest) { const { email, code } = await req.json(); const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1';
// Rate-limit verify by IP — protects the brute force surface const ipLimit = await otpVerifyLimiter.attempt(`otp:verify:ip:${ip}`); if (!ipLimit.allowed) { return NextResponse.json({ error: 'Too many attempts' }, { status: 429 }); }
const user = await db.user.findUnique({ where: { email: email.toLowerCase() }, }); // Generic error to avoid enumeration const fail = NextResponse.json({ error: 'Invalid or expired code' }, { status: 400 }); if (!user) return fail;
const row = await db.otpCode.findFirst({ where: { userId: user.id, purpose: 'login', consumedAt: null, expiresAt: { gt: new Date() }, }, orderBy: { expiresAt: 'desc' }, }); if (!row) return fail;
// Per-code attempt counter — caps brute force at MAX_ATTEMPTS per issuance if (row.attempts >= MAX_ATTEMPTS) { await db.otpCode.delete({ where: { id: row.id } }); return fail; }
// hash.verify is constant-time const ok = await otpHash.verify(code, row.codeHash);
if (!ok) { await db.otpCode.update({ where: { id: row.id }, data: { attempts: row.attempts + 1 }, }); return fail; }
// Consume the code (one-time use) await db.otpCode.update({ where: { id: row.id }, data: { consumedAt: new Date() }, });
// Optional: mark email as verified since they proved ownership of the inbox if (!user.emailVerifiedAt) { await db.user.update({ where: { id: user.id }, data: { emailVerifiedAt: new Date() }, }); }
await auth().login(user);
return NextResponse.json({ ok: true });}The handoff is one line: await auth().login(user). Everything before it is OTP plumbing; everything after it is your normal authenticated app.
SMS delivery
Section titled “SMS delivery”The flow is identical — only the transport changes. Use a transactional SMS provider (Twilio, Vonage, MessageBird) and rate-limit aggressively because SMS costs real money.
import { Twilio } from 'twilio';const sms = new Twilio(process.env.TWILIO_SID!, process.env.TWILIO_AUTH!);
await sms.messages.create({ to: user.phone, from: process.env.TWILIO_FROM!, body: `Your sign-in code is ${code}. It expires in 10 minutes.`,});OTP as a second factor
Section titled “OTP as a second factor”For step-up auth — confirming a sensitive action, or backing up TOTP — use the same issueOtp / verify routes with a different purpose:
// Issuing a code to confirm a withdrawalconst code = await issueOtp(user.id, 'confirm-withdrawal');await sendEmail({ to: user.email, subject: `Withdrawal code: ${code}`, /* ... */ });
// Verifying it — same shape, just `purpose: 'confirm-withdrawal'` in the lookupStoring OTPs by (userId, purpose) means a 2FA code can’t accidentally be replayed against the login endpoint and vice versa.
Brute force math
Section titled “Brute force math”A 6-digit code = 1,000,000 possibilities. Without limits, an attacker brute-forces it instantly. Three layers stop them:
| Layer | Cap | What it stops |
|---|---|---|
Per-code attempts counter | 5 | Brute-forcing a single live code |
| Verify rate limit by IP | 5 / 15 min | Distributed guessing from one IP |
| 10-minute TTL | — | The window in which guessing matters |
With all three: an attacker gets 5 guesses against a code that lives 10 minutes — odds of success are 5 in 1,000,000, or 0.0005%. That’s the bar.
Code format choices
Section titled “Code format choices”| Format | Pros | Cons |
|---|---|---|
| 6-digit numeric | Easiest to type on mobile, works in SMS, fits in a notification preview | Smaller keyspace |
| 8-digit numeric | 100× larger keyspace | Annoying to type |
Alphanumeric (e.g. K3RQ-7XPM) | Bigger keyspace, easier to read in email | Worse on mobile keyboards; case confusion (O/0, I/l) |
Word-based (e.g. correct-horse-battery) | Friendly | Bad for SMS, easy to mistype |
6-digit numeric is the default for a reason — pick something else only if you have a specific reason.
Security checklist
Section titled “Security checklist”-
crypto.randomInt— notMath.random(). - Hash the code at rest —
createHash().make(code), never plaintext. - Constant-time verify —
createHash().verify()does this; don’t roll your own===. - Per-code attempt counter — caps brute force per issuance.
- TTL ≤ 10 minutes — limits the brute force window.
- Invalidate prior codes on issue —
deleteManyof unconsumed rows. - Single-use — set
consumedAton success. - Rate limit issuance — by IP and by email/phone. SMS especially: lock down or pay the bill.
- Rate limit verification — by IP.
- Generic error messages —
Invalid or expired codefor every failure path. No enumeration. - Don’t put the code in URLs — request logs, browser history, referrer headers.
- HTTPS only — codes over HTTP leak to every hop.
When not to use OTP
Section titled “When not to use OTP”- The user always reads email on the device they’re signing in from. Use magic links — fewer clicks, no typing.
- You need offline auth. OTP delivery requires connectivity. TOTP (authenticator app) works offline — see 2FA.
- Phishing resistance matters. OTP codes can be phished in real time. Passkeys are phishing-resistant.
- You’re sending SMS to high-value accounts at scale. Costs add up fast and SMS is the weakest delivery channel; switch to email OTP or passkeys.