Short answer: Put a suppression check before every Node.js SMS OTP send, record the decision separately from delivery, and move the actual 2FA dispatch into a transactional outbox worker. A blocked number should produce an auditable suppressed result, not a provider call and not a fake delivery failure.
| Design | Integration effort | Audit quality | Best fit |
|---|---|---|---|
| Local policy gate plus outbox | Medium | Explicit policy and delivery records | B2B SaaS with compliance reviews |
| Provider-managed suppression only | Low | Split across application and provider | Small, single-channel products |
| Central communications service | High | Strong across many channels and teams | Larger organizations with platform staff |
For a one-person SaaS that ships weekly, the first design is the practical default. It keeps business policy in the application, outsources message transport behind a narrow adapter, and avoids building a full communications platform. The catch is operational ownership: you must maintain the suppression data and worker. Stick with provider-managed suppression when one account sends one kind of message and its audit export already satisfies the reviewer; build a central service only when several products genuinely need the same policy engine.
How should a Node.js SMS OTP 2FA flow handle blocked numbers?
Treat suppression as an authorization decision, not as cleanup after a rejected send. The login handler verifies that the account may begin an authentication challenge, normalizes the destination into the same representation used by the suppression store, and asks a policy gate whether dispatch is allowed. Only an allowed decision creates a send job. The worker later exchanges that job for a provider message identifier, while the audit log preserves both decisions.
Order matters.
If the application creates an OTP, calls an SMS API, and checks a blocked-numbers list afterward, the check has no value. If it checks only when a user enrolls in 2FA, a number suppressed on Tuesday can still receive a code on Friday. The gate belongs on the dispatch path, and the outbox record plus the policy decision should commit with the auth challenge. That transaction prevents an awkward state where the UI says a challenge exists but no durable work item explains what should happen next.
This design also separates three meanings that are often collapsed into a boolean called sent: suppressed means policy intentionally prevented contact, queued means durable work exists, and accepted means the transport acknowledged the request. None proves handset receipt. Calling all three “delivered” makes a compliance notice easy to demo and hard to defend.
Keep those states separate.
Do not expose the distinction in the login response. OWASP's forgot-password guidance calls for consistent messages and consistent timing for existing and nonexistent accounts, along with rate limiting against repeated requests. The same anti-enumeration principle fits an OTP login: return one generic result while recording the precise internal outcome for authorized operators.
The two criteria that earn their keep
The primary criterion is integration effort over the next six months, not the number of lines in the first pull request. A provider-only list looks wonderfully small until support, account closure, user opt-out, fraud controls, and a second delivery channel all need different meanings for “blocked.” A homegrown communications platform has the opposite problem: it solves every hypothetical routing question before the SaaS has enough revenue to justify an on-call rotation.
Use one application-owned table with scoped reasons and a narrow transport adapter. The table should answer who made the decision, which tenant and channel it applies to, when it took effect, and whether it expires. The adapter should know how to send and return a transport reference. It should not decide whether a compliance notice is lawful or appropriate. That boundary keeps vendor replacement out of auth code and leaves policy visible during an audit — useful work, not infrastructure theater.
The second criterion is audit semantics. A reviewer should be able to connect one login challenge to one policy decision and, when allowed, one dispatch attempt without reading mutable application logs. Use opaque challenge and attempt identifiers rather than the OTP itself. Record timestamps, state transitions, a policy reason code, and a provider reference after acceptance. Restrict access and retention according to the system's actual obligations; the supplied security guidance does not prescribe a universal retention period, so that number has to come from counsel and the applicable policy.
Audit the decision.
I'm not sure a global suppression scope is defensible for every multi-tenant product. A person may block messages from one tenant without blocking another. Resolve that question with the product's consent model and legal review, then encode the answer as data instead of burying it in worker logic.
A small TypeScript implementation
The example below keeps the interfaces deliberately plain. There is no SDK in the domain layer, and there are no invented HTTP routes. A database implementation should run createChallenge, appendAudit, and enqueue in one transaction. The in-memory shape shown here concentrates on the decision sequence.
import { createHmac, randomInt, randomUUID } from "node:crypto";
type SuppressionReason = "user_request" | "account_closed" | "fraud_hold";
type AuditState = "suppressed" | "queued" | "accepted" | "failed";
interface SuppressionStore {
findActive(tenantId: string, phoneE164: string): Promise<
{ reason: SuppressionReason; effectiveAt: string } | undefined
>;
}
interface ChallengeStore {
create(input: {
id: string;
tenantId: string;
phoneE164: string;
otpDigest: string;
expiresAt: string;
}): Promise<void>;
}
interface Outbox {
enqueue(input: {
attemptId: string;
challengeId: string;
tenantId: string;
phoneE164: string;
template: "login_otp";
}): Promise<void>;
}
interface AuditLog {
append(input: {
attemptId: string;
challengeId: string;
state: AuditState;
occurredAt: string;
reason?: SuppressionReason;
transportRef?: string;
}): Promise<void>;
}
const digestOtp = (challengeId: string, otp: string, secret: string): string =>
createHmac("sha256", secret).update(`${challengeId}:${otp}`).digest("hex");
export async function requestLoginOtp(input: {
tenantId: string;
phoneE164: string;
otpSecret: string;
suppressions: SuppressionStore;
challenges: ChallengeStore;
outbox: Outbox;
audit: AuditLog;
}): Promise<{ challengeId: string }> {
const challengeId = randomUUID();
const attemptId = randomUUID();
const now = new Date();
const blocked = await input.suppressions.findActive(
input.tenantId,
input.phoneE164,
);
if (blocked) {
await input.audit.append({
attemptId,
challengeId,
state: "suppressed",
occurredAt: now.toISOString(),
reason: blocked.reason,
});
return { challengeId };
}
const otp = randomInt(0, 1_000_000).toString().padStart(6, "0");
const expiresAt = new Date(now.getTime() + 10 * 60_000).toISOString();
await input.challenges.create({
id: challengeId,
tenantId: input.tenantId,
phoneE164: input.phoneE164,
otpDigest: digestOtp(challengeId, otp, input.otpSecret),
expiresAt,
});
await input.outbox.enqueue({
attemptId,
challengeId,
tenantId: input.tenantId,
phoneE164: input.phoneE164,
template: "login_otp",
});
await input.audit.append({
attemptId,
challengeId,
state: "queued",
occurredAt: now.toISOString(),
});
return { challengeId };
}
The six-digit code and ten-minute expiry are example policy values, not universal recommendations. OWASP permits a 6–12 digit PIN when it is generated with a cryptographically secure random source, stored securely, single-use, and expired after an appropriate period. A production verifier still needs constant comparison behavior, attempt limits, one-time consumption, and invalidation when a newer challenge supersedes an older one. The snippet hashes the code with a server secret so the audit trail never needs to contain it. One detail deserves extra scrutiny: the three writes after the suppression check are shown as separate interface calls so the example stays readable, but they need transactional behavior in the database implementation. I would reject a design review that could persist the challenge and lose the outbox item on a process exit. Make the transaction boundary explicit in real code.
Test the policy boundary, then operate the queue
Most tests should target states, not transport mocks. Cover an active tenant-scoped suppression, an expired suppression, two tenants sharing the same destination, a repeated login request, and a worker retry that reuses the same attemptId. Assert that a blocked recipient creates zero outbox rows and one suppressed audit event. Assert that an allowed recipient creates one challenge and one queued attempt in the same commit. Then test the adapter contract separately with the transport's sandbox or test credentials.
Retries need idempotency. A worker can lose its connection after a transport accepts a request but before the application records accepted; blindly generating a fresh attempt turns that uncertainty into duplicate codes. Carry the stable attempt identifier through the adapter when the transport supports an idempotency facility, and reconcile ambiguous attempts rather than treating every retry as new work. Your mileage may vary because provider contracts differ, which is exactly why this behavior belongs behind the adapter.
Retries aren't new sends.
Watch queue age, attempts stuck in queued, suppression decisions by reason, and the ratio of accepted attempts to challenges. Do not put phone numbers or OTP values in metric labels. Page on a growing oldest-job age, not on every individual transport rejection. A solo founder's revenue-per-hour is better spent fixing a stalled auth path than triaging expected policy denials one by one.
For email fallback, keep channel policy separate. Yahoo's sender guidance tells bulk senders to honor unsubscribes, process complaints, and remove invalid recipients. Those controls support the same high-level lesson — contact eligibility is state — but an email unsubscribe must not be silently treated as an SMS 2FA block. Give each channel an explicit reason and scope.
Ship the schema and decision log first, behind a feature flag if needed. Then move dispatch to the worker, rehearse a retry, and export one complete audit chain. No grand platform rewrite.
When is the runner-up better?
Provider-managed suppression is a good fit when the product has one tenant boundary, one SMS transport, and no requirement to explain policy decisions from its own database. It removes a table and some operational work. Its limitation is portability: the auth service still needs a durable mapping from its challenge to the external attempt, and changing transports means migrating policy state or accepting a new source of truth.
A central communications service is better when several teams send compliance notices across SMS and email, shared consent rules are already defined, and someone owns the service operationally. It can standardize reason codes, retention, adapters, and audit exports. It isn't a good fit for a solo SaaS with one auth flow; the control plane, deployment, and support burden consume weeks that could have shipped customer-facing work.
The decision rule is blunt. Keep the policy gate beside the product until duplicated rules appear in two real systems. Outsource transport because it is undifferentiated. Do not outsource the meaning of “may contact this recipient” when that meaning is part of the evidence a compliance reviewer will ask to see.
References
- OWASP, “Forgot Password Cheat Sheet”: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- Yahoo, “Sender Best Practices and Requirements”: https://senders.yahooinc.com/best-practices/
Top comments (0)