Step 7
Step 7 — Email Verification and OTP
0 views
Step 7 — Email Verification and OTP
Sign-up, password reset, confirming a sensitive action. Sending a one-time code by email to prove "you really own this address" is a flow used almost everywhere.
This step builds it line by line, hands-on — from SMTP setup through OTP generation, verification, and rate limiting. The heart of the security here is not clever code; it is three small habits that block code leakage, reuse, and unbounded sending.
1. SMTP setup — app password
First you need a channel to send mail through. Assuming Gmail as the sending server, the connection details are:
| Item | Value |
|---|---|
| host | smtp.gmail.com |
| port | 587 |
| encryption | STARTTLS |
| user | the sending Gmail address |
| password | app password (not the account login password) |
This is where people get stuck. Gmail does not allow SMTP access with the normal login password. Issue an app password as follows:
- Google Account → Security → turn on 2-Step Verification (the next menu only appears once this is on)
- Security → open App passwords
- Enter an app name and generate → you get a 16-character string
- Use those 16 characters as the SMTP password
Never hardcode credentials in code. Keep them in environment variables.
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=no-reply@example.com
SMTP_PASS=xxxxxxxxxxxxxxxx
EMAIL_FROM="My Service <no-reply@example.com>"
Baked-in credentials live forever in repository history and force a redeploy every time you rotate the key.
2. Sending mail — nodemailer / JavaMailSender
With a channel in place, send one message. In Node, use nodemailer.
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: false, // 587 → STARTTLS, so secure is false
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
export async function sendOtpMail(to: string, code: string) {
await transporter.sendMail({
from: process.env.EMAIL_FROM,
to,
subject: "Verification code",
text: `Your verification code is ${code}. Enter it within 5 minutes.`, // plain-text fallback
html: `<p>Your verification code is <strong>${code}</strong>.</p>
<p>Enter it within 5 minutes.</p>`,
});
}
The reason for sending both text and html is that some mail clients and screen readers cannot render the HTML body properly. A plain-text fallback means the code is readable everywhere.
In Java (Spring), JavaMailSender does the same job.
@Service
public class MailService {
private final JavaMailSender sender;
public void sendOtp(String to, String code) throws MessagingException {
MimeMessage msg = sender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(msg, true, "UTF-8");
helper.setTo(to);
helper.setSubject("Verification code");
// third arg is plain-text, fourth is HTML
helper.setText(
"Your verification code is " + code + ".",
"<p>Your verification code is <strong>" + code + "</strong>.</p>");
sender.send(msg);
}
}
3. Generating and expiring the OTP
Now create the code to send. Do not use Math.random(). It is a predictable pseudo-random generator — guess the seed and you can predict the next code. Use a cryptographically secure source.
import { randomInt } from "crypto";
function generateOtp(): string {
// 000000 - 999999, keep leading zeros
return String(randomInt(0, 1_000_000)).padStart(6, "0");
}
In Java, that is SecureRandom.
private static final SecureRandom RANDOM = new SecureRandom();
private String generateOtp() {
return String.format("%06d", RANDOM.nextInt(1_000_000));
}
Store the generated code together with an expiry. If you have Redis, its TTL is the expiry, which is the cleanest option.
// key: otp:<email>, value: code, auto-deleted after 5 minutes
await redis.set(`otp:${email}`, code, "EX", 300);
Without Redis, put an expiry timestamp in a DB column.
CREATE TABLE email_otps (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
code TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
attempts INT NOT NULL DEFAULT 0,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Five minutes is usually enough for the expiry. Too long, and a leaked code stays valid for too long.
4. Verifying the OTP
When the user submits a code, check three things — match, not expired, not used.
async function verifyOtp(email: string, input: string): Promise<boolean> {
const stored = await redis.get(`otp:${email}`);
if (!stored) return false; // missing or already expired (TTL gone)
if (stored !== input) {
// count wrong attempts — block when too many
const tries = await redis.incr(`otp:${email}:tries`);
await redis.expire(`otp:${email}:tries`, 300); // same lifetime as the OTP — avoids stale count carrying over
if (tries >= 5) await redis.del(`otp:${email}`);
return false;
}
// success — single-use: delete immediately to block reuse
await redis.del(`otp:${email}`, `otp:${email}:tries`);
return true;
}
The key point is to delete the code right after a successful verification. If you do not, the same code can be used multiple times and "one-time" loses its meaning. Also, once wrong attempts exceed a set count (say 5), discard the code. Six digits is one in a million, but allowing unlimited attempts lets automation brute it. Give the attempt counter the same expiry so the next OTP starts clean.
5. Rate limiting — resend throttle
Just as important as verification, you must block abuse on the sending side. Hitting the resend button endlessly floods the inbox and can get the sending account flagged as spam. Allow only one send per minute per email.
async function canResend(email: string): Promise<boolean> {
const key = `otp:resend:${email}`;
// SET NX — create only if the key is absent, 60s TTL
const ok = await redis.set(key, "1", "EX", 60, "NX");
return ok === "OK"; // null means 60s has not passed yet
}
// send handler
if (!(await canResend(email))) {
return Response.json(
{ error: "Please try again shortly." },
{ status: 429 }
);
}
SET ... NX means "write only if the key is absent," which fits a resend lock exactly. Press again within 60 seconds and the key already exists, so null comes back and you block there.
6. Send-disabled mode
In dev and test environments, firing a real email every time clutters the inbox and eats into your sending quota. Turn off sending with a single flag and print the code to the console.
const EMAIL_ENABLED = process.env.EMAIL_ENABLED === "true";
export async function sendOtpMail(to: string, code: string) {
if (!EMAIL_ENABLED) {
console.info(`[mail:dev] to=${to} code=${code}`);
return; // skip the real send
}
await transporter.sendMail({ /* ... */ });
}
Put EMAIL_ENABLED=false in the dev .env and true in production. Tests can read the code from the console log and carry on.
Sending can also fail (a transient SMTP outage, for example). When it does, isolate it so that a failed send does not break the whole user request. Process the sign-up itself, and offer a separate "resend code" button.
try {
await sendOtpMail(email, code);
} catch (e) {
// only the send failed — keep the sign-up flow, expose a resend button
logger.warn("otp mail send failed", e);
}
Gotchas
- Storing the OTP in plaintext and leaving it — single-use and expiry are the point. With no expiry or deletion, the code stays valid forever
- No rate limit — endless resend clicks flood the inbox and get the sending account flagged as spam
- Synchronous send blocking the request — the user request stalls while waiting on the SMTP response. Move it to a queue or async path
- From domain / account mismatch — if the
EMAIL_FROMaddress differs from the authenticated SMTP account, mail gets marked as spam or rejected - Real sends in the test environment — running without a flag like
EMAIL_ENABLEDships codes to real inboxes
Closing
Email verification is little code, but it all comes down to keeping three rules: the code lives briefly, dies once used, and cannot be sent without limit. Cover those three and the rest is ordinary mail delivery.
Next
- security/01-jwt-rotation
- backend/14-email-otp
🎉 You finished Web security foundations — JWT · OAuth · OWASP
What's next? Pick another course below.