codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
EDU›Web security foundations — JWT · OAuth · OWASP›Step 7

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:

  1. Google Account → Security → turn on 2-Step Verification (the next menu only appears once this is on)
  2. Security → open App passwords
  3. Enter an app name and generate → you get a 16-character string
  4. 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_FROM address 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_ENABLED ships 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

← Step 6

Anonymous form hardening

🎉 You finished Web security foundations — JWT · OAuth · OWASP

What's next? Pick another course below.

Next: PostgreSQL in depth + Redis · Kafka →Browse all courses