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 Amazon SES
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 @aws-sdk/client-sesv2- Verify the sending identity in SES.
- Move out of the SES sandbox before sending production OTPs to arbitrary recipients.
- Configure bounce and complaint handling so bad addresses do not keep receiving OTP attempts.
- Set AWS_REGION 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 Amazon SES
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 { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
const ses = new SESv2Client({ region: process.env.AWS_REGION ?? "us-east-1" });
export async function sendOtpEmail(email: string, code: string, purpose: string) {
await ses.send(new SendEmailCommand({
FromEmailAddress: process.env.OTP_FROM_EMAIL ?? "[email protected]",
Destination: {
ToAddresses: [email],
},
Content: {
Simple: {
Subject: {
Charset: "UTF-8",
Data: `Your Acme verification code is ${code}`,
},
Body: {
Text: {
Charset: "UTF-8",
Data: `Your verification code is ${code}. It expires in 10 minutes.`,
},
Html: {
Charset: "UTF-8",
Data: `<p>Your verification code is <strong>${code}</strong>.</p>
<p>It expires in 10 minutes.</p>`,
},
},
},
},
EmailTags: [{ Name: "purpose", Value: purpose }],
}));
}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 Amazon SES. 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.
- SES can accept or reject an outbound message, but your app still owns the OTP challenge ID and verification semantics.
- SES delivery, bounce, complaint, and throttling signals need to feed back into your OTP flow and rate limits.
- A production SES OTP build also needs sandbox exit, identity verification, IAM scoping, suppression handling, and region-aware observability.
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 Amazon SES 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 Amazon SES 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 use Amazon SES for OTP emails?
Yes. SES can send verification code emails. It does not provide the OTP state machine that decides when codes are created, reused, expired, or locked.
Which SES API should I use for OTP email?
For a normal code email, SES v2 SendEmail with Simple content is enough. Use templates or raw MIME only when your product needs those features.
What is the SES-specific work beyond OTP logic?
You need verified identities, production access if you are in the sandbox, IAM permissions, bounce and complaint processing, and monitoring for throttling.