Short Answer
The basic tutorial is straightforward: generate a code, save a challenge, send the email, and verify the submitted code later. That works in a happy-path demo. The production work starts when users click resend, emails arrive out of order, attackers guess codes, or delivery happens after the TTL is already gone.
Set Up Resend
Start with the provider account and sender identity. Keep this layer boring: the provider should receive one clear transactional message with the code and enough tags to debug delivery.
pnpm add resend- Create a Resend API key.
- Verify the sending domain you will use for OTP mail.
- Send from a product-owned address such as [email protected].
- Set RESEND_API_KEY in your server environment.
- Set OTP_FROM_EMAIL in your server environment.
Create The OTP Challenge First
The email send should be downstream from your OTP state. Create the challenge first, store only a hash of the code, and keep the plaintext code in memory only long enough to render the email.
import { createHash, randomInt, randomUUID } from "node:crypto";
function createCode() {
return randomInt(0, 1_000_000).toString().padStart(6, "0");
}
function hashCode(code: string, secret: string) {
return createHash("sha256").update(`${secret}:${code}`).digest("hex");
}
export async function createOtpChallenge(email: string, purpose: string) {
const code = createCode();
const challenge = {
id: randomUUID(),
email: email.trim().toLowerCase(),
purpose,
codeHash: hashCode(code, process.env.OTP_HASH_SECRET ?? ""),
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
attemptsRemaining: 5,
};
await db.otpChallenges.insert(challenge);
return { challengeId: challenge.id, code };
}This is intentionally incomplete. It sends a working OTP, but it does not yet handle idempotent resend. The first version shows the shape of the work before we tighten the state machine.
Send The Code With Resend
Once the challenge exists, pass the email address and code to the provider. Keep the message short, transactional, and specific to the action the user started.
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendOtpEmail(email: string, code: string) {
await resend.emails.send({
from: `Acme <${process.env.OTP_FROM_EMAIL}>`,
to: [email],
subject: `Your Acme verification code is ${code}`,
text: `Your verification code is ${code}. It expires in 10 minutes.`,
html: `<p>Your verification code is <strong>${code}</strong>.</p>
<p>It expires in 10 minutes.</p>`,
});
}Verify Against Your Challenge State
Verification should check the challenge ID, expiration, attempt budget, and hashed code. Do not search for any matching code across all users or purposes.
export async function verifyOtp(challengeId: string, submittedCode: string) {
const challenge = await db.otpChallenges.findById(challengeId);
if (!challenge || challenge.expiresAt <= new Date()) {
return { ok: false, reason: "expired" };
}
if (challenge.attemptsRemaining <= 0) {
return { ok: false, reason: "locked" };
}
const submittedHash = hashCode(submittedCode, process.env.OTP_HASH_SECRET ?? "");
if (submittedHash !== challenge.codeHash) {
await db.otpChallenges.decrementAttempts(challengeId);
return { ok: false, reason: "invalid" };
}
await db.otpChallenges.markVerified(challengeId);
return { ok: true };
}What You Just Did Not Build
At this point you can send a code email with Resend. You have not yet built the parts that make OTP reliable under real user behavior.
Idempotent resend
Same email and purpose should reuse the same active code for the TTL instead of replacing it.
Code hashing
The database should store a hash of the code, not the plaintext answer visible in the email.
Attempt lockout
Verification needs a max-attempt budget so a six-digit code does not become an unlimited guessing game.
Rate limits
Limit sends and verifies by tenant, email, IP, purpose, and active challenge.
Delivered within TTL
Provider acceptance is not the same as the user receiving a usable code before expiration.
Purpose isolation
A login code should not verify signup, report access, account deletion, or another high-risk action.
- Resend sends the message, but it does not decide whether resend returns the same active code or creates a new one.
- You still need to hash the code at rest and compare submitted codes without storing the plaintext answer.
- You still need a verify attempt budget, lockout state, send rate limits, and a way to know whether delivery happened before the OTP expires.
Make Resend A Read-Or-Create
The key production change is to look for an active challenge before generating a new code. If one exists for the same tenant, normalized email, and purpose, reuse it and send another email containing the same code.
sendOtp(email, purpose):
key = tenant_id + normalize(email) + purpose
active = findActiveChallenge(key, now)
if active:
sendEmail(active.code)
return active.id, active.expiresAt, reused: true
challenge = createChallenge(key, ttl: 10 minutes)
sendEmail(challenge.code)
return challenge.id, challenge.expiresAt, reused: falseThis is the behavior the user expects. Clicking resend should help them receive the answer, not silently change the answer while an older email is still visible.
The Two-Endpoint Shortcut
If you use Resend directly, your app owns both the sender integration and the OTP state machine. sendotp.email moves the OTP behavior behind two endpoints.
POST /v1/send
{
"email": "[email protected]",
"purpose": "login"
}
POST /v1/verify
{
"id": "otp_01JZ4NQ8F2T7G2A9J6P0G5QX3K",
"code": "493021"
}The product invariant is the part these tutorial builds tend to rediscover late: the same email plus the same purpose returns the same active OTP for 10 minutes.
Official Provider Docs
Provider setup details change over time, so use the official provider reference when wiring the sender integration.
sendotp.email shortcut
Use Resend to send mail, or skip the OTP state machine.
sendotp.email is built for the behavior these tutorials need after the first demo works: stable 10-minute codes, purpose isolation, hashed verification, attempt budgets, and calm resend semantics.
FAQ
Can I send OTP emails with Resend?
Yes. Resend can deliver the OTP message. Your application still owns code generation, hashing, resend idempotency, expiration, and verification attempts.
Should every Resend resend create a new verification code?
Usually no. For the same email and purpose, reuse the active code during the TTL so older and newer emails do not fight each other.
What does sendotp.email replace in a Resend OTP build?
It replaces the OTP challenge state machine: send, stable resend, hashed verification, attempt limits, rate limits, and purpose-scoped expiry.