Passport.js Email OTP with Rovela (2026): Custom Strategy + Session
Express + Passport custom email-OTP strategy: app mints crypto.randomInt + HMAC codes, Rovela POST /emails delivers, then req.login establishes the Passport session. Distinct from plain Express OTP without Passport.
Passport owns the session — Rovela only delivers the code
Passport.js is Express’s classic authentication middleware: strategies verify credentials, then req.login / passport.session() establish a server-side session (or whatever serializer you configure). Passport does not mint email OTPs, store HMAC hashes, or speak SMTP. There is no first-party “email OTP strategy” that talks to a mail API for you.
This guide wires a custom Passport strategy that accepts { email, code }, verifies against your OTP store, and logs the user in with Passport’s session stack. Your app still mints six-digit codes with crypto.randomInt, stores an HMAC (never the digits), and calls Rovela POST https://api.rovela.dev/emails with an Idempotency-Key so delivery is transactional and retry-safe (Email verification codes, Send Email API, Quick Start).
Rovela does not provide an OTP or Passport feature. It does not create users, check codes, call req.login, or set cookies. Your Express + Passport stack owns cryptography, the database row, rate limits, redeem logic, and the session. Rovela only queues, DKIM-signs, and delivers the message.
Distinct from:
- Send OTP Emails in Express.js — plain Express routes that send/verify codes and then issue a session or JWT without Passport’s strategy/
req.loginintegration - Auth.js / NextAuth + Rovela / Better Auth + Rovela — different auth frameworks, not Passport strategies
- Sending OTP codes by email — language-agnostic OTP overview
What Passport does vs what Rovela sends
| Concern | Owner |
|---|---|
Minting crypto.randomInt codes + HMAC at rest | Your app |
POST /emails, DKIM, queue, bounces | Rovela |
Custom strategy verify callback | Passport + your OTP store |
serializeUser / deserializeUser / session cookie | Passport + express-session (or equivalent) |
| Rate limits on send/verify | Your Express middleware |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Prerequisites
- Verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Prefer a dedicated subdomain such asmail.example.comfor auth mail (Why OTP and magic-link emails go to spam). - An API key shaped like
re_…. Prefersending_accessfor production senders (Authentication). - Express app with
express-session(or another session store Passport can use),passport, and a user lookup by email. - Keep
ROVELA_API_KEY,CODE_PEPPER, and session secrets on the server only—never in a browser bundle.sending_accessnever belongs in the client.
Environment
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <security@mail.example.com>'
export CODE_PEPPER='long-random-server-secret'
export SESSION_SECRET='another-long-random-secret'
Rotate Rovela keys by creating a new one, deploying, then revoking the old key (Authentication).
OTP crypto + store (app-owned)
Same rules as the docs example: CSPRNG digits, HMAC with a pepper, expiry, attempt counter, one send per minute (verification-code example).
// src/otp/crypto.ts
import crypto from "node:crypto";
const PEPPER = process.env.CODE_PEPPER!;
export function normalizeEmail(raw: string) {
return raw.trim().toLowerCase();
}
export function mintCode(): string {
// OS CSPRNG — Math.random() is not for secrets
return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
}
export function hashCode(email: string, code: string) {
return crypto
.createHmac("sha256", PEPPER)
.update(`${email}:${code}`)
.digest("hex");
}
export function safeEqualHex(a: string, b: string) {
const ba = Buffer.from(a, "hex");
const bb = Buffer.from(b, "hex");
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
Suggested row shape (one active code per email):
CREATE TABLE verification_codes (
email TEXT PRIMARY KEY,
code_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
attempts INT NOT NULL DEFAULT 0,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Rovela sender helper
Centralize fetch so send routes share headers, error handling, and idempotency. Do not ask Rovela to invent codes—pass the digits you already minted into html/text only.
// src/rovela.ts
const ROVELA_URL = "https://api.rovela.dev/emails";
export async function sendEmail(opts: {
to: string;
subject: string;
html: string;
text: string;
idempotencyKey: string;
}) {
const apiKey = process.env.ROVELA_API_KEY!;
const from = process.env.ROVELA_FROM!;
const key = opts.idempotencyKey.slice(0, 256);
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({
from,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
// Do not log API keys — status + provider message only
console.error("rovela_send_failed", res.status, data.message);
throw new Error(`Rovela send failed: ${res.status}`);
}
return res.json() as Promise<{ object: string; id: string }>;
}
Notes aligned with Rovela’s docs:
- Send both
htmlandtextwhen you can (Send Email API). Idempotency-Key(≤ 256 chars): a retry within 24 hours returns the original acceptance instead of mailing twice. Prefer a key derived from the code hash (stable per minted challenge), not a fresh UUID on every network attempt after you already persisted the row.frommust be on a verified domain or the API responds400.
Send route: mint → store → Rovela (no Passport yet)
Passport enters on verify + login. Sending mail is still a plain Express handler—same transport pattern as the Express OTP guide, but the next section is what makes this Passport-specific.
// src/routes/otpSend.ts
import { Router } from "express";
import rateLimit from "express-rate-limit";
import { mintCode, hashCode, normalizeEmail } from "../otp/crypto.js";
import { sendEmail } from "../rovela.js";
// import { db } from "../db.js";
export const otpSendRouter = Router();
const sendLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
});
otpSendRouter.post("/auth/otp/send", sendLimiter, async (req, res) => {
const email = normalizeEmail(String(req.body?.email ?? ""));
if (!email.includes("@")) {
return res.status(400).json({ error: "invalid_email" });
}
const recent = await db.query(
`SELECT 1 FROM verification_codes
WHERE email = $1 AND sent_at > NOW() - INTERVAL '60 seconds'`,
[email],
);
if (recent.rowCount > 0) {
return res.status(429).json({ error: "too_soon" });
}
const code = mintCode();
const codeHash = hashCode(email, code);
await db.query(
`INSERT INTO verification_codes (email, code_hash, expires_at, attempts, sent_at)
VALUES ($1, $2, NOW() + INTERVAL '10 minutes', 0, NOW())
ON CONFLICT (email) DO UPDATE
SET code_hash = EXCLUDED.code_hash,
expires_at = EXCLUDED.expires_at,
attempts = 0,
sent_at = NOW()`,
[email, codeHash],
);
await sendEmail({
to: email,
subject: `Your Acme code: ${code}`,
html:
`<p>Your verification code is</p>` +
`<p style="font-size:28px;letter-spacing:6px"><b>${code}</b></p>` +
`<p>It expires in 10 minutes. If you did not ask for it, ignore this email.</p>`,
text: `Your verification code is ${code}. It expires in 10 minutes.`,
idempotencyKey: `passport-otp-${codeHash.slice(0, 32)}`,
});
// Uniform body if you hide account existence
return res.status(200).json({
message: "If that address can receive mail, a code is on its way.",
});
});
Putting the code in the subject helps lock-screen previews—skip it on highly shared devices (verification-code docs).
Custom Passport strategy: verify OTP → req.login
Passport’s Strategy API expects you to call done(err, user) (or done(null, false) on failure). Implement a strategy that reads email + code from the request body, checks your store with constant-time compare, deletes the row on success, and returns the user record Passport will serialize into the session.
// src/auth/otpStrategy.ts
import passport from "passport";
import { Strategy } from "passport-strategy";
import {
hashCode,
normalizeEmail,
safeEqualHex,
} from "../otp/crypto.js";
// import { db, findUserByEmail } from "../db.js";
type Done = (err: any, user?: Express.User | false, info?: object) => void;
class EmailOtpStrategy extends Strategy {
name = "email-otp";
authenticate(this: Strategy & { success: Function; fail: Function; error: Function }, req: any) {
const email = normalizeEmail(String(req.body?.email ?? ""));
const code = String(req.body?.code ?? "").trim();
if (!email.includes("@") || !/^\d{6}$/.test(code)) {
return this.fail({ message: "invalid" }, 400);
}
void (async () => {
try {
// Claim attempt + read hash in one statement (same idea as docs)
const { rows } = await db.query(
`UPDATE verification_codes SET attempts = attempts + 1
WHERE email = $1 AND attempts < 5 AND expires_at > NOW()
RETURNING code_hash`,
[email],
);
if (rows.length === 0) {
return this.fail({ message: "invalid" }, 401);
}
const expected = rows[0].code_hash as string;
const actual = hashCode(email, code);
if (!safeEqualHex(expected, actual)) {
return this.fail({ message: "invalid" }, 401);
}
await db.query(`DELETE FROM verification_codes WHERE email = $1`, [email]);
let user = await findUserByEmail(email);
if (!user) {
// Product choice: auto-provision on first OTP, or reject
user = await createUser({ email });
}
return this.success(user);
} catch (err) {
return this.error(err);
}
})();
}
}
export function registerOtpStrategy() {
passport.use(new EmailOtpStrategy());
}
Wire session serialization the way any Passport app does—OTP does not change this:
// src/auth/session.ts
import passport from "passport";
// import { findUserById } from "../db.js";
passport.serializeUser((user: any, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id: string, done) => {
try {
const user = await findUserById(id);
done(null, user || false);
} catch (err) {
done(err);
}
});
Express bootstrap: session → Passport → routes
// src/app.ts
import express from "express";
import session from "express-session";
import passport from "passport";
import { registerOtpStrategy } from "./auth/otpStrategy.js";
import "./auth/session.js";
import { otpSendRouter } from "./routes/otpSend.js";
const app = express();
app.use(express.json());
app.use(
session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
},
}),
);
app.use(passport.initialize());
app.use(passport.session());
registerOtpStrategy();
app.use(otpSendRouter);
app.post("/auth/otp/verify", (req, res, next) => {
passport.authenticate("email-otp", (err: any, user: Express.User | false, info: any) => {
if (err) return next(err);
if (!user) {
return res.status(401).json({ error: info?.message ?? "invalid" });
}
// Passport session integration — this is the contrast with plain Express OTP
req.login(user, (loginErr) => {
if (loginErr) return next(loginErr);
return res.status(200).json({ ok: true, user: { id: (user as any).id, email: (user as any).email } });
});
})(req, res, next);
});
app.post("/auth/logout", (req, res, next) => {
req.logout((err) => {
if (err) return next(err);
req.session.destroy(() => res.status(204).end());
});
});
app.get("/me", (req, res) => {
if (!req.isAuthenticated?.() || !req.user) {
return res.status(401).json({ error: "unauthenticated" });
}
return res.json({ user: req.user });
});
Contrast with Express OTP: that article stops at “code matched → issue session/JWT yourself.” Here the match lives inside a Passport strategy, and req.login + passport.session() own the cookie lifecycle so the rest of your middleware (req.isAuthenticated(), deserializeUser, logout) stays consistent with Local/OAuth strategies you may already run.
Deliverability and ops checklist
- Authenticate SPF/DKIM/DMARC for the subdomain in
ROVELA_FROM(DKIM setup, DMARC for SaaS). - Keep auth mail on a dedicated subdomain so product newsletters cannot burn the same reputation (spam pitfalls).
- Prefer
sending_accesskeys in production senders; usefull_accessonly when you need to read message events (Authentication). - Align OTP TTL (example: 10 minutes) with the copy in the email—Rovela does not expire codes for you.
- On retries after timeouts, reuse a stable
Idempotency-Keyderived from the challenge so users do not receive two disagreeing codes (Idempotency). - Rate-limit both
/auth/otp/sendand/auth/otp/verify; Passport does not replace abuse controls.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Rovela 400 on send | from domain not verified, or missing html/text/template |
Rovela 401 | Missing/invalid Bearer key (Authentication) |
| Send returns 200 but no mail | Queued/delayed/filtered—check domain auth and spam; inspect events with full_access if needed |
| Duplicate emails on retry | Missing or non-stable Idempotency-Key |
Verify succeeds but /me is 401 | Forgot passport.session(), serializeUser, or req.login |
| Strategy never runs | Wrong strategy name in passport.authenticate, or body parser missing |
Summary
Mint OTPs in your Express app (crypto.randomInt + HMAC), deliver them with Rovela POST /emails and a stable Idempotency-Key, then verify inside a custom Passport strategy and call req.login so sessions match the rest of your Passport stack. Rovela remains the transactional delivery layer—Passport remains the session layer.
Next steps: Quick Start · Send Email API · Email verification codes · Express OTP (without Passport)