Skip to content

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).


lib/auth.ts
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).


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);

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.

lib/otp.ts
import { randomInt } from 'node:crypto';
import { otpHash } from './auth';
import { db } from './db';
const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const 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)
}

app/auth/otp/request/route.ts
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;
}
app/auth/otp/verify/route.ts
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.


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.`,
});

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 withdrawal
const 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 lookup

Storing OTPs by (userId, purpose) means a 2FA code can’t accidentally be replayed against the login endpoint and vice versa.


A 6-digit code = 1,000,000 possibilities. Without limits, an attacker brute-forces it instantly. Three layers stop them:

LayerCapWhat it stops
Per-code attempts counter5Brute-forcing a single live code
Verify rate limit by IP5 / 15 minDistributed guessing from one IP
10-minute TTLThe 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.


FormatProsCons
6-digit numericEasiest to type on mobile, works in SMS, fits in a notification previewSmaller keyspace
8-digit numeric100× larger keyspaceAnnoying to type
Alphanumeric (e.g. K3RQ-7XPM)Bigger keyspace, easier to read in emailWorse on mobile keyboards; case confusion (O/0, I/l)
Word-based (e.g. correct-horse-battery)FriendlyBad for SMS, easy to mistype

6-digit numeric is the default for a reason — pick something else only if you have a specific reason.


  • crypto.randomInt — not Math.random().
  • Hash the code at restcreateHash().make(code), never plaintext.
  • Constant-time verifycreateHash().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 issuedeleteMany of unconsumed rows.
  • Single-use — set consumedAt on 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 messagesInvalid or expired code for 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.

  • 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.