DEV Community

Keria
Keria

Posted on

Express JS OTP Login: Auditable SMS-to-Email Passwordless Access

Short answer: make the audit record and the login attempt the source of truth, then let SMS be the first challenge and email be a separately recorded fallback. For a logistics portal sending a generated report as an attachment, the useful result is not merely “verified”; it is proof of which challenge was accepted, for which report, and when.

That decision keeps the authentication path explainable. It also prevents a familiar mistake: treating the delivery channel as proof of identity. A provider can accept a message without the person entering its code, and an email can arrive after a newer attempt has started.

The data flow is small. Express creates a report-scoped attempt, records an SMS challenge, and returns an opaque attempt ID. The user can request email only against the verified address already held for the account. Whichever valid code wins is consumed once; the parent attempt is then closed and the report download is authorized against that same scope.

Evidence first.

What should an Express JS OTP login preserve for an auditable passwordless sign-in?

Start with the evidence boundary. A parent login attempt needs an ID, account ID, tenant ID, report ID, creation time, expiry time, and status such as open or consumed. A child challenge needs its channel, a destination fingerprint, requested and accepted timestamps, a provider reference when available, an attempt counter, and a final result. Store these fields in application storage, not only in a provider dashboard.

For a generated delivery report, the report ID is part of the authentication context. If R-1048 is the attachment being requested, a successful challenge for R-1048 must not silently become permission to download every report in the tenant. That authorization check belongs after authentication and before the attachment response.

Store a keyed digest of an email OTP rather than the code itself. Generate it with a cryptographically secure source, expire it server-side, decrement the guess counter for every submission, and remove it after success. SMS verification may be performed by a channel adapter, but the application still owns the parent attempt and its evidence.

Do not put the OTP, a provider response body, or a complete phone number in an ordinary audit event. A reviewer needs “email challenge accepted for report R-1048 at 14:03,” along with the event ID and account scope. They do not need another copy of the secret.

How can Express JS coordinate SMS OTP, email fallback, and one winning login?

The parent attempt is the concurrency boundary. SMS and email can be live briefly, but verification must lock or compare-and-set the parent, consume the winning child, create one session, and close the other child as one storage operation. Two requests arriving milliseconds apart should not produce two sessions.

Here is a compact TypeScript example for the application-owned part. The channel adapters are deliberately interfaces: an SMS or email provider’s request shape belongs in one adapter, while the Express handlers deal in normalized results. The in-memory store makes the state transitions readable; production storage must be shared across processes and must enforce the same atomic transition.

import { createHmac, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
import type { Request, Response } from "express";

type Attempt = {
  id: string;
  accountId: string;
  tenantId: string;
  reportId: string;
  status: "open" | "consumed" | "expired";
  expiresAt: number;
};

type EmailChallenge = {
  id: string;
  attemptId: string;
  digest: Buffer;
  expiresAt: number;
  guessesLeft: number;
};

const attempts = new Map<string, Attempt>();
const emailChallenges = new Map<string, EmailChallenge>();
const otpKey = process.env.EMAIL_OTP_KEY;

if (!otpKey) throw new Error("EMAIL_OTP_KEY is required");

function digest(challengeId: string, code: string): Buffer {
  return createHmac("sha256", otpKey)
    .update(`${challengeId}:${code}`)
    .digest();
}

export function beginReportLogin(
  accountId: string,
  tenantId: string,
  reportId: string,
): Attempt {
  const attempt: Attempt = {
    id: randomUUID(),
    accountId,
    tenantId,
    reportId,
    status: "open",
    expiresAt: Date.now() + 10 * 60_000,
  };
  attempts.set(attempt.id, attempt);
  return attempt;
}

export function createEmailFallback(attemptId: string): {
  challengeId: string;
  code: string;
} {
  const attempt = attempts.get(attemptId);
  if (!attempt || attempt.status !== "open" || attempt.expiresAt <= Date.now()) {
    throw new Error("login attempt is not open");
  }

  const challengeId = randomUUID();
  const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
  emailChallenges.set(challengeId, {
    id: challengeId,
    attemptId,
    digest: digest(challengeId, code),
    expiresAt: Date.now() + 5 * 60_000,
    guessesLeft: 5,
  });
  return { challengeId, code };
}

export function verifyEmailFallback(
  attemptId: string,
  challengeId: string,
  submittedCode: string,
): boolean {
  const attempt = attempts.get(attemptId);
  const challenge = emailChallenges.get(challengeId);
  if (
    !attempt || !challenge || challenge.attemptId !== attemptId ||
    attempt.status !== "open" || attempt.expiresAt <= Date.now() ||
    challenge.expiresAt <= Date.now() || challenge.guessesLeft <= 0
  ) {
    return false;
  }

  challenge.guessesLeft -= 1;
  const candidate = digest(challengeId, submittedCode);
  const matches = timingSafeEqual(candidate, challenge.digest);
  if (matches || challenge.guessesLeft === 0) {
    emailChallenges.delete(challengeId);
  }
  if (matches) attempt.status = "consumed";
  return matches;
}

export function reportDownload(req: Request, res: Response): void {
  const attempt = attempts.get(String(req.query.attemptId));
  const reportId = String(req.params.reportId);
  if (!attempt || attempt.status !== "consumed" || attempt.reportId !== reportId) {
    res.sendStatus(403);
    return;
  }
  res.sendStatus(200); // Attach the generated report after the authorization check.
}
Enter fullscreen mode Exit fullscreen mode

The last function is intentionally only an authorization boundary, not a report generator. A real handler should fetch the attachment using tenantId and reportId, emit an audit event, and stream the file. The important invariant is that a valid OTP does not erase the resource scope.

The example also has a hard production warning. A Map disappears on restart and is different in each Express process, so it cannot be the shared login store. Put the records behind a database transaction, a conditional update, or an equivalent compare-and-set primitive. Send the generated email code from a worker or adapter; never return it from a route or log it.

Where do SMS and email fallback break the report workflow?

The fallback timer is a user-interface decision, not a delivery verdict. “Try email” is accurate. “SMS failed” may not be. Accepted, delivered, and verified are distinct states, and callbacks do not turn an unentered OTP into identity proof.

SMS length also belongs in the test plan. GSM-7 and UCS-2 encoding can affect segmentation, so the exact message text, destination countries, and character set need testing. Keep the code prominent and the message short. A long operational message is a bad place to spend characters.

Email has a different set of edges: filtering, delayed inboxes, stale addresses, and an old message opened after a resend. Give every challenge an ID, bind the code to that ID, and make a resend create an explicit new child challenge. Do not extend all previous expirations as a side effect.

One race deserves a real integration test. A valid SMS and a valid email submission can reach the server together. The atomic state transition must record one accepted result, issue one session, close the parent, and write one final audit outcome. Browser timers and disabled buttons cannot enforce that rule.

How should a solo SaaS founder test and operate this passwordless access path?

Test the boundaries first: an unknown account gets the same public response as a known one; an expired or reused code cannot create a session; every bad guess reduces the counter; a fallback uses only the account’s verified email; and a report from another tenant fails authorization after a successful login.

Then test the storage behavior under concurrency. Submit two valid codes at once and assert one session, one consumed attempt, and one final audit event. Test a resend while the first message is delayed. Test a worker retry. Test an attachment request with a valid session but the wrong report ID. This is where a single-process demo tends to mislead: the first request can read open, pause while a session is created, and let the second request read the same open state. In a real deployment, that pause can be a database round trip, a queue handoff, or a slow provider callback. The fix is not a faster browser button. The state transition itself must reject the second request, and the audit writer must use the same accepted transition so a retry cannot manufacture a second “verified” event.

Rate-limit starting a challenge, resending it, and guessing independently. Combine account, attempt, destination, and network signals rather than trusting an IP address alone. SMS spend and unusual country or carrier patterns deserve alerts because the channel is both an attack surface and a billable resource.

The integration choice can stay explicit:

Approach Integration boundary Fits when Main limitation
Application-owned email code Express generates and verifies the code You need a small, inspectable state machine Delivery, filtering, and retry behavior remain your responsibility
Transactional email service Express owns evidence; a mail service handles delivery You need managed email delivery Accepted delivery is not proof that the code was entered
SMS verification service Express owns the parent attempt; an SMS adapter normalizes provider results A known phone is the first channel Carrier delay, segmentation, and SIM-related risk remain part of the threat model

The table is a design boundary, not a ranking.

The trade-off is clear: this pattern is not suitable for phishing-resistant authentication, offline access, or a product that needs a complete hosted identity lifecycle with enrollment and recovery policy. Choose passkeys or an identity platform when those requirements are central. Keep SMS plus email for a report workflow only when the compliance owner accepts the channel and retention model.

My operational checklist is short: persist the parent before sending, use an outbox or equivalent for retries, normalize provider results in one adapter, measure requested-to-verified time by channel, and retain audit events separately from expiring OTP records. I’m not sure one retention period fits every carrier contract or regulatory regime; that value needs a decision from legal and security owners.

References

Top comments (0)