← Back to blog

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.

Updated November 4, 2026
Passport.js Email OTP with Rovela (2026): Custom Strategy + Session

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:

What Passport does vs what Rovela sends

ConcernOwner
Minting crypto.randomInt codes + HMAC at restYour app
POST /emails, DKIM, queue, bouncesRovela
Custom strategy verify callbackPassport + your OTP store
serializeUser / deserializeUser / session cookiePassport + express-session (or equivalent)
Rate limits on send/verifyYour Express middleware

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

Prerequisites

  1. Verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer a dedicated subdomain such as mail.example.com for auth mail (Why OTP and magic-link emails go to spam).
  2. An API key shaped like re_…. Prefer sending_access for production senders (Authentication).
  3. Express app with express-session (or another session store Passport can use), passport, and a user lookup by email.
  4. Keep ROVELA_API_KEY, CODE_PEPPER, and session secrets on the server only—never in a browser bundle. sending_access never 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 html and text when 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.
  • from must be on a verified domain or the API responds 400.

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

  1. Authenticate SPF/DKIM/DMARC for the subdomain in ROVELA_FROM (DKIM setup, DMARC for SaaS).
  2. Keep auth mail on a dedicated subdomain so product newsletters cannot burn the same reputation (spam pitfalls).
  3. Prefer sending_access keys in production senders; use full_access only when you need to read message events (Authentication).
  4. Align OTP TTL (example: 10 minutes) with the copy in the email—Rovela does not expire codes for you.
  5. On retries after timeouts, reuse a stable Idempotency-Key derived from the challenge so users do not receive two disagreeing codes (Idempotency).
  6. Rate-limit both /auth/otp/send and /auth/otp/verify; Passport does not replace abuse controls.

Troubleshooting

SymptomLikely cause
Rovela 400 on sendfrom domain not verified, or missing html/text/template
Rovela 401Missing/invalid Bearer key (Authentication)
Send returns 200 but no mailQueued/delayed/filtered—check domain auth and spam; inspect events with full_access if needed
Duplicate emails on retryMissing or non-stable Idempotency-Key
Verify succeeds but /me is 401Forgot passport.session(), serializeUser, or req.login
Strategy never runsWrong 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)