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 codePOST /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 keyotp:<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>; comparehash(code)tocodeHash. - On success, delete the key to consume. On failure, increment
attemptsand enforce lockouts. - Upsert
usersbynormalized_emailand 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
codeHashin 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.