Short answer: use an OTP endpoint for 2FA login, and use direct SMS only for public-sector appointment alerts that contain no authentication secret. Keep those two message paths separate even when they share a delivery adapter. The deciding factor isn't a cheap per-message quote. It's who owns the code template, expiry, attempt counter, and replay protection.
For a generated appointment report sent as an email attachment, make the application own the report template and attachment lifecycle. Authentication codes are different: delegating their lifecycle to a purpose-built OTP boundary removes security state from the alerting code. One transport stack can still sit underneath both workflows, but one template model shouldn't.
That is the build choice. It has a catch: a managed OTP endpoint is the wrong fit when policy requires the agency to render, store, and audit every message body itself. In that case, direct send can be justified, but the application also inherits every authentication control listed below.
How should a Node.js auth app compare direct SMS with an OTP endpoint?
Start with ownership, not syntax. A direct SMS API accepts a destination and a body. The auth app generates the code, places it in a template, stores enough state to verify it, expires it, limits guesses, and makes successful use final. An OTP endpoint moves that code lifecycle behind a narrower pair of operations: start a challenge and verify a submitted code.
That difference matters for appointment alerts because the same phone number may receive two message classes. “Your appointment is at 09:30” is a notification. “Your login code is 381204” is a credential. Treating both as arbitrary strings makes the first demo pleasantly small, then leaves the application responsible for a security protocol it never named.
I benchmark this kind of choice by counting application-owned states before counting milliseconds. The direct path needs at least pending, expired, consumed, blocked, and superseded behavior, plus a decision about concurrent challenges. The OTP path still needs application session state, but code generation and code verification stay on the other side of the adapter. Less glue wins here — provided that boundary matches the agency's retention and audit rules.
The browser doesn't erase that distinction. MDN describes WebOTP as a way for a web app to obtain a specially formatted one-time code from an SMS after user consent, in a secure context. It can reduce retyping, but it isn't the issuer, verifier, or policy engine. The server must still decide whether a challenge is valid.
No magic.
The constraint that changed the choice
Template ownership sounds cosmetic until one system sends both appointment reports and login codes. The report is content: its column labels, date range, accessibility wording, attachment name, and localization belong with the public-service workflow. The login code is protocol data. Letting a general report renderer own both creates an awkward dependency in which an editorial template change can touch authentication behavior.
The clean boundary is three ports: an appointment notifier, a report mailer, and an OTP challenger. The notifier may render ordinary SMS. The mailer may attach the generated report. The challenger should expose intent rather than a message body. This also stops a developer from accidentally logging the finished 2FA text while debugging an appointment template.
Trace one rescheduled appointment through that design. The scheduling event carries an appointment identifier, a new time, and a stable event identifier. The notification worker loads the authorized contact, renders the alert, and passes only the finished non-secret text to DirectSmsSender; a duplicate event identifier is rejected before another message is created. A separate request to download the generated report first requires an authenticated session. OtpEndpoint.start returns an opaque challenge identifier, the session stores that identifier, and OtpEndpoint.verify resolves the submitted code without exposing code generation to the report service. Only after authentication does the report job render the agency-owned document and hand its validated bytes to ReportMailer. Three outcomes can now be inspected independently: alert delivery, authentication, and report delivery. If they shared one generic template pipeline, a retry, a log entry, or a localization edit could cross boundaries that should never meet. That is the concrete reason to tolerate three small interfaces instead of celebrating one universal messaging client.
Keep the seams visible.
There is an email-specific trap too. SPF is useful for checking whether a host is authorized to use a domain in the SMTP identity described by RFC 7208. It doesn't validate an attachment, prove that a report is safe, or define the visible message template. So keep sender authorization, report generation, attachment validation, and delivery status as separate checks. A passing SPF result can't stand in for the other three.
I'm not sure a single retention policy can satisfy every public-sector jurisdiction; the answer depends on the applicable records schedule and threat model. That uncertainty should resolve in requirements, not in an environment variable added the night before launch.
The smallest working TypeScript boundary
The useful example is an interface, not a vendor SDK. It makes the direct-versus-OTP decision visible and keeps HTTP details out of the appointment service.
type E164 = string & { readonly e164: unique symbol };
type AppointmentAlert = {
to: E164;
startsAt: string;
locationName: string;
};
type OtpChallenge = {
challengeId: string;
};
interface DirectSmsSender {
send(input: { to: E164; body: string }): Promise<{ messageId: string }>;
}
interface OtpEndpoint {
start(input: { to: E164 }): Promise<OtpChallenge>;
verify(input: { challengeId: string; code: string }): Promise<{ valid: boolean }>;
}
interface ReportMailer {
send(input: {
to: string;
subject: string;
attachment: { filename: string; bytes: Uint8Array; mediaType: string };
}): Promise<{ messageId: string }>;
}
The alert path owns its words. Keep it boring and free of secrets.
function renderAppointmentAlert(alert: AppointmentAlert): string {
return `Appointment at ${alert.startsAt}, ${alert.locationName}.`;
}
async function sendAppointmentAlert(
sms: DirectSmsSender,
alert: AppointmentAlert,
): Promise<string> {
const sent = await sms.send({
to: alert.to,
body: renderAppointmentAlert(alert),
});
return sent.messageId;
}
The login path never accepts a body. That tiny omission is deliberate: application code can't interpolate a code into an appointment template because it never receives the code.
type LoginSession = {
id: string;
otpChallengeId?: string;
authenticatedAt?: string;
};
async function beginLogin(
otp: OtpEndpoint,
session: LoginSession,
phone: E164,
): Promise<LoginSession> {
const challenge = await otp.start({ to: phone });
return { ...session, otpChallengeId: challenge.challengeId };
}
async function finishLogin(
otp: OtpEndpoint,
session: LoginSession,
submittedCode: string,
): Promise<LoginSession> {
if (!session.otpChallengeId) throw new Error("Missing OTP challenge");
const result = await otp.verify({
challengeId: session.otpChallengeId,
code: submittedCode,
});
if (!result.valid) throw new Error("Invalid OTP");
return {
id: session.id,
authenticatedAt: new Date().toISOString(),
};
}
Notice what isn't in this sample: a generated code, a code database column, a reusable message body, or a vendor route guessed from REST conventions. The adapter can use plain HTTP or an SDK behind these interfaces. The application contract stays the same.
The email report follows the other ownership rule. Generate and validate bytes before invoking the mail port; pass a fixed media type and a sanitized filename; record a report identifier separately from the delivery identifier. Don't put attachment bytes or login codes in logs. It's tempting to use one generic sendMessage() function for all three paths. Don't. That abstraction saves a few lines and erases the controls that matter.
What I would change at scale
First, I would make every operation idempotent at the application boundary. Appointment jobs retry. Workers restart. A stable event identifier should prevent one scheduling event from producing repeated alerts or repeated report emails. OTP start requests need their own duplicate-request policy so a retry doesn't create confusing parallel challenges.
Second, measure outcomes by message class. For alerts, track accepted, delivered, and permanently failed states without storing the body. For reports, track generation, attachment validation, submission, and delivery separately. For authentication, track challenge starts, verification success, expiry, and blocked attempts — again without recording the submitted code. A single “messages sent” counter hides every useful failure mode.
Third, test the contracts with fakes and test rendered content with snapshots only where the application truly owns the template. An OTP fake should issue an opaque challenge ID and accept a known test code; it should not encourage production code to generate codes locally. The appointment renderer deserves tests for dates, locations, empty fields, and localization. The report pipeline deserves checks for filename, media type, non-empty bytes, and recipient authorization.
Then load-test the workflow, not just the transport call. A queue draining 10,000 reminder jobs can be fast while the backing appointment database becomes the bottleneck. I would record queue delay, adapter latency, and end-to-end completion independently, then test retry behavior under throttling. Your mileage may vary because delivery constraints and traffic shape vary, but those three timings show where the glue is actually costing time.
Keep configuration narrow. Separate credentials and policies may be necessary, but duplicating provider selection, sender identity, locale, timeout, retry, and logging switches in every service is config bloat. Put transport wiring in adapters; keep template and authentication decisions in domain code.
Trade-offs and a decision rule
| Concern | Direct SMS send | OTP endpoint |
|---|---|---|
| Template ownership | Application supplies the full alert body | Authentication flow supplies intent, not arbitrary body text |
| Security state | Application owns code creation, expiry, attempts, replay, and consumption | OTP boundary owns the code challenge lifecycle |
| Best fit | Non-secret appointment notifications; tightly controlled self-owned auth systems | Ordinary 2FA login where less application security state is preferred |
| Poor fit | Teams that can't maintain and review an authentication protocol | Agencies required to own and retain the exact authentication template and challenge state |
| Testing focus | Rendering, localization, deduplication, and delivery | Session binding, challenge transitions, and verification outcomes |
Choose the OTP endpoint when the requirement is “prove this user controls this phone number.” Choose direct SMS when the requirement is “send this appointment information,” or when a documented governance rule requires full ownership of the authentication template and state. For generated report attachments, use a separate mail interface and keep the report template in the appointment domain.
Cost belongs in the benchmark, but it isn't the architecture. Compare the full unit: outbound messages, verification operations, retries, regional reach, support, engineering time, and the operational burden of code lifecycle state. A low send price can still attach to the more expensive system to own. Exact totals need current quotes and the agency's real traffic distribution, so I'm not going to invent a universal winner.
Measure first.
The limitation is clear. Managed OTP isn't suitable when data residency, records retention, accessibility review, or procurement terms require controls the endpoint cannot satisfy; direct send with a reviewed in-house challenge service may then be the defensible choice. Conversely, don't choose direct send merely because its first Node.js example is shorter. The missing lines are the security system.
References
Further reading
The SPF specification defines the narrow email authorization claim; the WebOTP documentation explains the browser-side SMS retrieval mechanism and its security requirements. Read both before treating transport convenience as proof of message or authentication correctness.
Top comments (0)