Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 7 min read

Nodemailer Alternative: Sending Email OTPs Through a 6-Stage 2FA Signup Contract

Short answer: use a provider-neutral delivery adapter for email OTPs, and choose a Nodemailer alternative only when its delivery contract fits your media signup flow; the application must still hash, expire, and consume

Short answer: use a provider-neutral delivery adapter for email OTPs, and choose a Nodemailer alternative only when its delivery contract fits your media signup flow; the application must still hash, expire, and consume the challenge itself.

Email OTP delivery for a media account signup is a contract problem before it is a provider problem. Keep one short-lived, hashed challenge per signup, send the verification message, and make the login decision from your database rather than from an email response. A replaceable delivery adapter then lets you change the service behind that contract without changing the signup flow.

The bill is usually less mysterious than the retention policy. One challenge creates one stored hash, one expiry timestamp, and a small amount of state for the delivery attempt; retaining every generated code, provider response, and message body forever is the change that grows the data footprint without improving verification. I retain the minimum needed to answer β€œcan this account prove control of this address right now?” and delete or age out the rest. That means an incident may leave less history to investigate, so I keep a request identifier and outcome, not the OTP or the full email content.

Should a Nodemailer alternative handle sending email OTPs for 2FA?

The application should own challenge generation, hashing, expiration, attempt limits, and the final state transition. The email service should own message submission and delivery-related status that it actually exposes. Those boundaries matter because an accepted send request is not proof that the user received anything.

For a media product, the useful sequence is six stages: create a signup challenge, persist its hash and expiry, render the OTP template, send the message, validate the submitted code, and mark the address verified. Before sending, check the address against the email suppression list with /v1/email/suppression/check/{email} when that check is part of your policy. A suppressed or undeliverable address should produce a clear retry or support path, not an infinite resend loop.

The security record can be small and boring. I would store a challenge identifier, account identifier, hash, expiration, attempt count, and consumed timestamp. The code never belongs in logs. I started out thinking delivery retries were the hard part; the sharper boundary is making every retry safe while ensuring an old code cannot become valid again.

import json
import os
import time

import requests

def send_email(payload, challenge_id):
    url = os.environ["INFRAI_BASE_URL"].rstrip("/") + "/v1/email/send"
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": challenge_id,
    }
    for attempt in range(4):
        response = requests.request("POST", url, headers=headers, json=payload, timeout=15)
        if response.status_code != 429:
            if not 200 <= response.status_code < 300:
                raise RuntimeError(f"email submission failed: {response.status_code} {response.text}")
            return response.json()
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)
    raise RuntimeError("email submission remained rate-limited after retries")

payload = json.loads(os.environ["OTP_EMAIL_PAYLOAD_JSON"])
send_email(payload, os.environ["OTP_CHALLENGE_ID"])

The payload is supplied by the caller because the provider's live schema, rather than a guessed field list, should define the message. The example uses an environment key, an explicit method, a client idempotency key, status checking, and bounded 429 backoff.

That is the boundary.

There is a second distinction that tends to disappear in a happy-path test. A successful API submission says that the delivery system accepted a request; it does not say that the address is reachable, that the message avoided a mailbox filter, or that the person will use the code before its expiry. For a media signup, the UI should therefore expose a bounded resend action and a recovery path, while the backend records the submission result separately from verification. A suppression check can prevent a known bad address from entering the send path, but it cannot turn an undeliverable mailbox into a deliverable one. Treating these states as separate is more work in the first sprint and much less ambiguous during an account dispute.

Consider the awkward but ordinary sequence: a viewer starts signup, requests a code, requests another after a delayed inbox refresh, then submits the first message. If the resend path overwrites the record without making the previous challenge explicitly invalid, two codes can appear plausible to the user while only one matches the database. If the send retry creates a second challenge, a timeout at the provider boundary can create the same ambiguity. A single challenge identifier, an atomic consume operation, and a resend policy that is visible in the record make the outcome deterministic. The email service cannot make that decision for you.

import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone

def issue_challenge():
    code = f"{secrets.randbelow(1_000_000):06d}"
    digest = hashlib.sha256(code.encode("ascii")).hexdigest()
    expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
    return code, digest, expires_at

def verify_challenge(code, stored_digest, expires_at, consumed_at=None):
    if consumed_at is not None or datetime.now(timezone.utc) >= expires_at:
        return False
    candidate = hashlib.sha256(code.encode("ascii")).hexdigest()
    return hmac.compare_digest(candidate, stored_digest)

The example deliberately stops at the application boundary. The verified integration route is /v1/email/send, and a reusable OTP message can be created with /v1/email/template/create; the request schema should come from the provider’s live discovery documentation rather than from a guessed payload copied into production. The delivery adapter should accept a stable challenge id or idempotency key, surface non-success responses, and apply bounded backoff to rate limits.

How do retention and delivery reliability interact?

Retention is a reliability decision with a security cost attached. Keep the active challenge until its expiry and until the verification attempt is resolved. Keep a compact audit outcome long enough for abuse investigation. Do not keep plaintext OTPs, rendered message bodies, or an unbounded resend history merely because the email vendor makes them easy to retrieve.\n+
The trade-off table is intentionally unsentimental:

Choice Helps Costs or limits
Store a salted or keyed hash only Limits exposure if the database is read Requires a deliberate verification path and rotation plan for the secret if a keyed hash is used
Keep full message content Helps reconstruct a disputed message Expands sensitive retention and still does not prove inbox delivery
Accept provider submission as success Keeps the signup path short Confuses submission with receipt and can strand users with blocked addresses
Allow unlimited resends May help a user who missed one message Enables abuse and makes old-code ordering harder to reason about

Email has no hosted OTP interface in the verified capability set, so the application must build this challenge lifecycle. There is also no SMTP relay, and scheduled email has no cancellation route; those are boundaries to design around, not details to hide in an adapter. The available events are pull-based rather than webhook-pushed, which limits how quickly a multi-channel fallback can react.

Which alternative fits the adapter boundary?

Nodemailer is a Node.js mail transport library, so it is a natural fit when your organization already operates or has access to an SMTP relay. It is not, by itself, a hosted delivery service or a suppression database. That makes ownership explicit, but it also leaves deliverability operations with you.

Resend is an API-first transactional email service with an official JavaScript-oriented integration path. It is attractive when the team wants a focused email API and modern developer ergonomics, while its narrower scope means an application still needs its own OTP state machine and a separate SMS fallback if email is unavailable.

SendGrid exposes transactional email APIs and SMTP relay options. That breadth can help a team with existing email operations, but it gives the adapter more provider-specific policy to test, especially around templates, suppression behavior, and event handling.

Amazon SES offers API and SMTP delivery within the AWS ecosystem. It can be a sensible choice where IAM, regional controls, and existing AWS operations are more important than having a small standalone email surface; the trade-off is that the surrounding operational model is part of the decision.

Infrai fits the narrow case where the application wants one plain REST API and one key for the delivery capability, with the ability to swap the service behind that contract without rewriting the signup workflow. The surface needs no SDK installation, and its public discovery surface exposes documented routes and schemas; adjacent backend capabilities can use the same key. That is useful only if the team accepts the limits above: it does not remove the need to own email OTP logic, it does not provide SMTP relay, and pull-only events are a poor foundation for instant delivery-state orchestration.

A decision rule for a media signup flow

Choose the smallest boundary that preserves control of the security decision. If the team already owns SMTP operations, Nodemailer may be the cleanest adapter. If it wants a specialized email API, compare Resend and SendGrid on their documented event, suppression, and template behavior. If AWS ownership is already a hard constraint, SES deserves the evaluation. If changing the backend provider without changing application code is the primary architectural requirement, a plain REST capability such as /v1/email/send is a reasonable candidate.

In every case, test the same failure cases: an expired code, a reused code, five rapid resend attempts, a suppressed address, a provider rejection, and a successful verification after a retry. The provider is replaceable; the database transition is not.

Further reading

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.