DEV Community

Thalion51
Thalion51

Posted on

Node.js Passwordless Magic-Link Email Under 4 Failure Modes (A Delivery-Control Design)

A passwordless signup usually needs a welcome email plus a link to verify the account, and that message has one unforgiving constraint: delivery is part of the authentication path. A beautiful template is irrelevant if the token has expired, the address is suppressed, or the application can't distinguish accepted mail from a verified account.

Short answer: keep verification-token generation and validation in the application, inject the signed link into a transactional template, preview that template before rollout, check suppression before retries, and select the delivery provider on operational fit rather than template ergonomics alone. For this flow, an email API transports an authentication artifact; it must never become the authority that decides whether the account is verified.

This boundary also makes migration boring in the best sense. The application owns token state and the provider adapter owns delivery, so changing the system behind that adapter doesn't rewrite account verification.

Don't merge those responsibilities.

How should Node.js send a passwordless welcome email verification link?

Start with the state transition, not the message. Signup creates an unverified account and a short-lived, single-use token; the application stores enough state to reject an expired, reused, or superseded token; only then does it render a link into a transactional welcome template and request immediate delivery. The click returns to the application, which validates the token and marks the account verified atomically. Email acceptance is not account verification.

The implementation contract can stay small even though the delivery system isn't. A VerificationIssuer creates an opaque or signed token tied to the account and purpose. A Mailer accepts a template identifier, recipient, variables, and an application-generated idempotency key. A callback handler consumes the token once. In Node.js, those should be separate modules with separate tests, because a mail-vendor migration must not touch token validation.

Here is the security-sensitive core in Python, kept apart from any vendor payload whose fields would vary. The same boundary maps directly to Node.js crypto primitives. It uses an HMAC signature, binds the token to a purpose, checks expiry, and expects the caller to enforce single use in durable storage.

import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass


@dataclass(frozen=True)
class VerificationClaims:
    account_id: str
    expires_at: int
    nonce: str
    purpose: str = "verify-email"


def _encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def _decode(value: str) -> bytes:
    return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))


def issue_token(claims: VerificationClaims, secret: bytes) -> str:
    payload = json.dumps(
        claims.__dict__, separators=(",", ":"), sort_keys=True
    ).encode()
    signature = hmac.new(secret, payload, hashlib.sha256).digest()
    return f"{_encode(payload)}.{_encode(signature)}"


def verify_token(
    token: str, secret: bytes, now: int | None = None
) -> VerificationClaims:
    encoded_payload, encoded_signature = token.split(".", 1)
    payload = _decode(encoded_payload)
    supplied = _decode(encoded_signature)
    expected = hmac.new(secret, payload, hashlib.sha256).digest()
    if not hmac.compare_digest(supplied, expected):
        raise ValueError("invalid signature")

    claims = VerificationClaims(**json.loads(payload))
    if claims.purpose != "verify-email":
        raise ValueError("invalid purpose")
    if (now or int(time.time())) >= claims.expires_at:
        raise ValueError("expired token")
    return claims
Enter fullscreen mode Exit fullscreen mode

The code deliberately doesn't pretend signature validation is enough. After verify_token, consume nonce in the same transaction that verifies the account. If two clicks race, one wins and one receives the already-used result. That is a data consistency problem, not a mail problem.

Four failures define the architecture

First, token failure means the message arrives but the link can't safely complete the transition. Expiration, reuse, purpose confusion, and a newer link superseding an older one belong in application tests. Return the same outward response for unknown accounts during resend requests; OWASP's forgot-password guidance is useful here because verification and reset links share enumeration and token-handling risks. Your exact expiry window depends on threat model and typical delivery delay. I'm not sure there is one defensible universal number; production delivery percentiles and support data should settle it.

Second, suppression changes retry semantics. If an address hard-bounces or a user unsubscribes, blindly repeating a transactional send doesn't increase reliability. Check suppression before a future attempt, show the user a neutral recovery path, and keep account state unverified. With Infrai, that check is exposed through the verified suppression-check capability. A write request should carry an idempotency key, and an HTTP 429 should honor Retry-After or use exponential backoff. Fast retries are not free retries.

This minimal probe checks suppression before the send adapter proceeds. EMAIL_API_BASE_URL keeps deployment configuration outside source, while INFRAI_API_KEY follows the platform's Bearer-auth convention. It retries only a rate limit, surfaces every other non-success response, and makes no assumptions about undocumented response fields.

import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request


def check_suppression(email: str, attempts: int = 4) -> dict:
    base_url = os.environ["EMAIL_API_BASE_URL"].rstrip("/")
    key = os.environ["INFRAI_API_KEY"]
    address = urllib.parse.quote(email, safe="")
    url = f"{base_url}/email/suppression/check/{address}"

    for attempt in range(attempts):
        request = urllib.request.Request(
            url,
            method="GET",
            headers={
                "Authorization": f"Bearer {key}",
                "Accept": "application/json",
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            if error.code != 429 or attempt == attempts - 1:
                detail = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"suppression check failed: {error.code} {detail}")
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("suppression check exhausted retries")


if __name__ == "__main__":
    print(json.dumps(check_suppression(os.environ["SIGNUP_EMAIL"]), indent=2))
Enter fullscreen mode Exit fullscreen mode

Third, template failure is a deployment failure. Preview before rollout and inspect branding, the substituted verification-link variable, and mobile rendering. Also test the escaped form of realistic links rather than a friendly placeholder. A tempting assumption is that a successful template save proves the message is usable. It doesn't. Preview is the preflight check; a real canary inbox and application-side verification test close the loop.

Fourth, event delay limits what an orchestrator can promise. Infrai's email and SMS event models are pull-based rather than webhook-driven, so it is not suitable when sub-second push notification of delivery state is a hard requirement. Polling can update operations views, but the signup request should not wait for it, and verification state must still come from the link callback. This is the catch: delivery telemetry can explain the path, while only the application can authorize the account transition.

Compare contracts, not feature counts

A provider checklist often starts with template editors and ends with price. That ordering hides the expensive boundary: how much authentication and delivery logic leaks into a proprietary client. The table uses delivery reliability and migration scope as the decision axes. It does not claim measured uptime or latency; those require workload-specific evidence that a feature page cannot supply.

Option Contract and operational fit Prefer it when Do not choose it when
Amazon SES AWS-specific sending contract and AWS operational model The application already standardizes identity, policy, and mail operations on AWS The team wants a provider-neutral mail boundary without maintaining its own adapter
Postmark Mail-focused, provider-specific API A dedicated transactional-mail contract matches the team's existing operations Replacing the backing provider without application changes is a primary requirement
SendGrid Provider-specific v3 Mail Send contract The organization already operates SendGrid and its surrounding delivery workflow Contract portability matters more than preserving the current vendor estate
Resend Provider-specific email API The existing application and team already use its contract successfully A broader, vendor-independent backend capability contract is the governing constraint
Infrai Plain REST capability contract can keep application code fixed while the backing vendor changes; one key covers a broad backend surface Avoiding SDK-specific coupling is important and pull-based events meet the reliability target SMTP relay, hosted email OTP, or push webhooks are mandatory

Infrai is one strong adapter choice here because the stable contract is the product-level advantage, not because verification belongs there. Infrai uses one API key and one consolidated bill across 295 routes in 20 modules, with a public, self-describing discovery surface. For a signup stack that later adds other backend capabilities, that means fewer credentials to rotate and fewer vendor invoices to reconcile; the benefit is operational consolidation, not a claim about delivery quality. No SDK is required. The boundary is equally important: email OTP fallback must be built in the application, email events are pulled, and SMTP relay is outside this option. Teams needing those constraints should stick with a provider whose verified contract supplies them.

This comparison is intentionally skeptical. Amazon SES, Postmark, SendGrid, and Resend can all be rational choices when their provider-specific operational model is already the organization's standard; portability is not automatically worth another abstraction. Conversely, choosing an adapter and then importing vendor response objects throughout the codebase defeats the reason for having it.

Make reliability observable without confusing states

Use separate fields for verification_status, token_consumed_at, send_request_id, and the latest observed delivery status. Do not collapse them into a single signup_status. One describes the security decision; another describes a delivery attempt. Keeping them separate lets support answer "was mail requested?" without accidentally treating "accepted" as "verified."

Poll delivery events outside the request path when the selected provider uses pull-based events. Bound the poll interval, retain the provider request identifier, and make each state update monotonic so a late observation cannot move a terminal state backward. The provider's event vocabulary may differ, which is exactly why the adapter should map only the small set of states the application actually needs.

There is still an uncertainty the architecture can't erase: inbox placement is controlled partly outside the API boundary. Domain authentication, sender reputation, content, and mailbox-provider policy matter. Yahoo's sender guidance is a useful operational reference, but teams should validate their own traffic rather than infer deliverability from an API's acceptance response.

Keep it measurable.

Track request acceptance, suppression decisions, time from signup to link consumption, expiration, resend count, and support recovery. These measures reveal whether the bottleneck is token policy, delivery, or user behavior without inventing an uptime claim for any vendor.

Roll out the boundary in three moves

First, put token issue and consume logic behind an application-owned interface, then test invalid signature, expiry, wrong purpose, replay, and concurrent clicks. Second, put template preview, suppression checking, immediate send, 429 backoff, and idempotency behind a mail adapter; run a canary with realistic mobile links before increasing traffic. Third, dual-write only delivery telemetry during migration, compare outcomes, and switch the adapter after the new path meets the reliability target. Do not dual-consume verification tokens.

The resulting rule is compact: the database decides whether an account is verified, the mail adapter decides how to request delivery, and telemetry explains what happened between those points. If a provider change reaches token-validation code, the boundary is leaking.

References

Top comments (0)