Express 2FA: passwordless sign-in, an SMS step-up, an email fallback

Middleware order, offline JWT checks against JWKS, an SMS second factor and the free email branch that catches it when the carrier doesn't deliver.

An Express 2FA flow is three decisions dressed up as routes: who issues the session, which middleware guards which router, and what happens when the text message never lands. Infrai covers all three from one key — POST /v1/auth/email/send_code and POST /v1/auth/email/verify do passwordless sign-in for free, POST /v1/sms/otp and POST /v1/sms/verify add the phone factor, and GET /v1/auth/token/jwks lets your middleware validate the resulting JWT without calling anyone.

The interesting part isn’t any single call. It’s the ordering — a guard that runs before body parsing sees no body, and a step-up check mounted above the login router locks users out of their own login. Twilio Verify will handle the phone factor beautifully and has nothing to say about either problem, which is roughly the division of labour this article assumes.

The stack, in order

import express from "express";

const app = express();
app.set("trust proxy", 1);
app.use(express.json({ limit: "8kb" }));   // 1. parse first
app.use(sessionFromBearer);                 // 2. decode, never enforce
app.use("/auth", authRouter);               // 3. open: login lives here
app.use("/api", requireSession, apiRouter); // 4. guarded
app.use("/api/billing", requireStepUp, billingRouter);
app.use(errorHandler);                      // 5. last, always
app.listen(3000);

Four rules hide in those six lines. Parsing comes first or your handlers get undefined. sessionFromBearer decodes and attaches but never rejects, so /auth stays reachable to a logged-out user. requireSession is mounted per-router rather than globally. And requireStepUp sits only on the routes that genuinely need a second factor, because charging a user an SMS to look at their dashboard is how you teach them to hate 2FA.

Order matters more than any single check.

Passwordless sign-in, two calls

The email path costs nothing and doubles as registration — created: true on the response means the user didn’t exist a second ago.

curl -X POST https://api.infrai.cc/v1/auth/email/send_code \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "email": "dana@example.com" }'
curl -X POST https://api.infrai.cc/v1/auth/email/verify \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "email": "dana@example.com", "code": "774102" }'
{
  "ok": true,
  "data": {
    "verified": true,
    "user_id": "usr_7Kd2mQ",
    "created": false,
    "session_id": "ses_c41a09",
    "access_token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImluZnJhaS1hdXRoLWVkMjU1MTktdjEifQ...",
    "refresh_token": "rft_9812ab",
    "expires_at": "2026-07-26T13:44:02Z"
  }
}

Verify is the login. There’s no separate session call to make afterwards, and the access_token is an EdDSA-signed JWT you can check locally.

Checking the session without a round trip

Fetch the key set once, cache it, and verify signatures in process. This is what keeps requireSession off the network on every request.

import { createPublicKey, verify as edVerify } from "node:crypto";

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY (placeholder: your_infrai_api_key)");

let jwksCache = null;
let jwksFetchedAt = 0;

async function jwks() {
  if (jwksCache && Date.now() - jwksFetchedAt < 3_600_000) return jwksCache;
  const res = await fetch(`${BASE}/v1/auth/token/jwks`, {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  if (!res.ok) throw new Error(`jwks fetch failed: ${res.status}`);
  const body = await res.json();
  jwksCache = body.data.keys;
  jwksFetchedAt = Date.now();
  return jwksCache;
}

function b64url(part) {
  return Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}

export async function sessionFromBearer(req, _res, next) {
  req.user = null;
  const token = (req.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
  const parts = token.split(".");
  if (parts.length !== 3) return next();
  try {
    const header = JSON.parse(b64url(parts[0]).toString("utf8"));
    const jwk = (await jwks()).find((k) => k.kid === header.kid);
    if (!jwk) return next();
    const pub = createPublicKey({ key: { ...jwk, kty: "OKP", crv: "Ed25519" }, format: "jwk" });
    const signed = Buffer.from(`${parts[0]}.${parts[1]}`, "utf8");
    if (!edVerify(null, signed, pub, b64url(parts[2]))) return next();
    const claims = JSON.parse(b64url(parts[1]).toString("utf8"));
    if (claims.exp && claims.exp * 1000 < Date.now()) return next();
    req.user = claims;
  } catch {
    req.user = null;
  }
  return next();
}

export function requireSession(req, res, next) {
  if (!req.user) return res.status(401).json({ error: "sign in first" });
  return next();
}

export function requireStepUp(req, res, next) {
  if (!req.user) return res.status(401).json({ error: "sign in first" });
  const at = req.session?.stepUpAt ?? 0;
  if (Date.now() - at > 15 * 60_000) return res.status(403).json({ error: "step_up_required" });
  return next();
}

The catch is that a cached JWKS means a rotated key takes up to an hour to appear. One hour is a reasonable default; if you rotate on a schedule, drop the TTL and re-fetch on an unknown kid.

The step-up router, with the fallback branch

import { Router } from "express";

const stepUp = Router();
const BASE_URL = "https://api.infrai.cc";
const API_KEY = process.env.INFRAI_API_KEY;

async function call(path, body) {
  const res = await fetch(`${BASE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    const message = json?.error?.message ?? `HTTP ${res.status}`;
    throw Object.assign(new Error(message), {
      status: res.status,
      code: json?.error?.code,
      permanent: /E\.164|format|invalid/i.test(message),
    });
  }
  return json.data;
}

stepUp.post("/challenge", requireSession, async (req, res, next) => {
  const { phone, email } = req.user;
  try {
    if (!phone) throw Object.assign(new Error("no phone on file"), { permanent: true });
    await call("/v1/sms/otp", {
      to: phone,
      template: "{code} is your Acme confirmation code.",
    });
    req.session.channel = "sms";
    return res.json({ channel: "sms", hint: phone.slice(-4) });
  } catch (err) {
    if (!err.permanent && err.status < 500) return next(err);
    await call("/v1/auth/email/send_code", { email });
    req.session.channel = "email";
    return res.json({ channel: "email", hint: email, degraded: true });
  }
});

stepUp.post("/confirm", requireSession, async (req, res, next) => {
  const code = String(req.body?.code ?? "");
  if (!/^\d{4,8}$/.test(code)) return res.status(400).json({ error: "bad code format" });
  try {
    const data = req.session.channel === "email"
      ? await call("/v1/auth/email/verify", { email: req.user.email, code })
      : await call("/v1/sms/verify", { to: req.user.phone, code });
    if (!data.verified) return res.status(401).json({ error: "wrong or expired code" });
    req.session.stepUpAt = Date.now();
    return res.json({ ok: true });
  } catch (err) {
    return next(err);
  }
});

export default stepUp;

The fallback fires on a permanent SMS failure or a 5xx, not on a wrong code. That distinction is the whole design: a user who typed the wrong digits should retry the same channel, and a user whose carrier bounced the message should get an email without pressing anything.

One error handler for a channel that lies

Bad recipients don’t come back as 400s. They arrive through the vendor path:

{
  "ok": false,
  "error": {
    "code": "VENDOR_DOWN",
    "http_status": 503,
    "message": "recipient not in E.164 format: '555-not-e164'",
    "retryable": true
  }
}

retryable: true on input that can never succeed is the reason call() above computes its own permanent flag from the message text. Express’s default error handler would return a 500 and let a client retry policy hammer the route; this one doesn’t:

export function errorHandler(err, _req, res, _next) {
  const status = err.permanent ? 400 : err.status ?? 500;
  const body = { error: err.permanent ? "invalid_input" : "upstream_unavailable" };
  if (err.code) body.code = err.code;
  if (!err.permanent && status >= 500) res.set("retry-after", "5");
  console.error(`[2fa] ${err.code ?? "ERR"} ${err.message}`);
  return res.status(status).json(body);
}

Note the retry-after header is one you set. Infrai doesn’t emit X-RateLimit-* or Retry-After on these routes, so there’s no upstream hint to pass through — a real limitation if you were hoping to proxy backoff advice straight to the client.

What each channel costs

StepRouteBilling
Passwordless sign-in codePOST /v1/auth/email/send_codeFree
Passwordless verify (issues JWT)POST /v1/auth/email/verifyFree
Session validationGET /v1/auth/token/jwks, then offlineFree
SMS step-up sendPOST /v1/sms/otpPer message
SMS step-up checkPOST /v1/sms/verifyPer call, charged even when wrong

Verified 2026-07-26: the SMS message runs about $0.0075 and the verify call $0.005, while the whole email path is $0. New accounts start with $2 free credit. Rates on this platform trend downward and campaigns run, so pull today’s figures instead of trusting a table:

curl -s https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '[.capabilities[] | select(.id=="sms.otp" or .id=="sms.verify" or .id=="auth.email.verify")
        | {id, billable: .billing.is_billable, price: .billing.price_usd}]'

The asymmetry is the design input. Email is free and SMS isn’t, so SMS belongs on genuinely sensitive actions and email belongs everywhere else — which is also, conveniently, the security ordering most teams want.

Where a specialist is the better call

If you need voice delivery, WhatsApp as a channel, or per-country routing rules you can edit in a console, Twilio Verify and Plivo both do things here that Infrai can’t do. Buy the specialist; the Express structure above doesn’t change, only the two functions inside call().

What you’d give up is the rest of the key. The same credential that verified this session sends the transactional email, holds the audit log, runs the cron that expires stale step-ups and shows per-tenant spend as a query rather than a reconciliation across two vendors. If phone verification is the only external dependency your app has, you’d be better off with a dedicated verification service — most Express apps have five or six, and that’s when consolidating stops being a slogan and starts being an afternoon saved every quarter.

References

Browse more sms developer guides