Skip to main content

Email OTP Login

Overview

Email OTP login uses Redis to store short-lived codes and issues stateless JWT on success. Deduplication uses normalized_email.

Endpoints

  • POST /auth/login/request-otp → request a code
  • POST /auth/login/verify-otp → verify code and sign-in

Request OTP

  • Normalize email: lower(trim(email)).
  • Generate random code (e.g., 6 digits), store {codeHash, attempts} in Redis key otp:<normalized_email> with TTL 10 minutes.
  • Rate-limit by IP/email using Redis counters.
  • Send code via email; return 202.

Verify OTP

  • Read Redis otp:<normalized_email>; compare hash(code) to codeHash.
  • On success, delete the key to consume. On failure, increment attempts and enforce lockouts.
  • Upsert users by normalized_email and issue JWT.

Pseudocode

const requestOtp = async (req, res) => {
const email = normalizeEmail(req.body.email);
const code = generateCode();
const codeHash = hash(code);
await redis.set(`otp:${email}`, JSON.stringify({ codeHash, attempts: 0 }), { EX: 60 * 10 });
await rateLimit(req, email);
await sendEmail(email, code);
return res.status(202).end();
};

const verifyOtp = async (req, res) => {
const email = normalizeEmail(req.body.email);
const raw = await redis.get(`otp:${email}`);
if (!raw) return res.status(401).end();
const state = JSON.parse(raw);
if (state.codeHash !== hash(req.body.code)) {
await bumpAttempts(email);
return res.status(401).end();
}
await redis.del(`otp:${email}`);

const user = await upsertUserByNormalizedEmail({ email });
const token = issueJwt({ sub: user.id, exp: days(7) });
return res.json({ token, user });
};

Security

  • Store only codeHash in Redis, never raw OTP.
  • Enforce rate limits and lockouts in Redis; set conservative TTLs.
  • Single-use consumption by deleting the key on success.

UX Tips

  • Show resend cooldown.
  • Clearly indicate the target email.
  • Support code autofill where possible.