DEV Community

RainerBarrett4745
RainerBarrett4745

Posted on

Node.js Passwordless Signup: Choosing Email, Phone, or OAuth Against Registration Bots

For an e-commerce signup flow, start with email verification, add a server-side captcha gate, and use OAuth when account continuity matters more than owning the identifier. The deciding constraint is abuse resistance: a smooth form is useless if a bot can create ten thousand accounts before your first alert fires.

Short answer: choose the smallest verification boundary that blocks your likely bot while preserving a recovery path; email is the usual baseline, phone raises the cost of disposable accounts, and OAuth is strongest when customers already have a stable identity with a provider.

How should a passwordless onboarding flow choose email, phone, or OAuth?

Treat these as different trust signals, not interchangeable buttons. Email proves control of an inbox. Phone proves control of a number, but numbers can be recycled or rented. OAuth delegates the login ceremony to a provider and can reduce typing, yet it does not automatically prove that the person is a good customer. None of them replaces a signup abuse policy.

I model the decision with two questions. How expensive is a fake account to the business? How painful is recovery when a customer loses access to the chosen identifier? A coupon-heavy store with public inventory has a different threat model from a private wholesale catalog. Keep that distinction in the design record; it will save you from arguing about “best” auth in a vacuum.

Email is a sensible first gate when shoppers already expect a receipt or order notice. Phone is worth the friction when one-account-per-person matters and SMS delivery is acceptable in your markets. OAuth belongs beside those choices when customers arrive from an existing identity and you can map provider subject IDs to your own user record.

Three words: don't guess.

Measure signup attempts, verification completion, resend rate, and post-signup abuse by route. I would rather delete a clever risk score than maintain a dashboard nobody trusts. Your mileage may vary because carrier filtering, regional email quality, and promotion economics are local variables.

The build log: two phases, one state transition

The implementation constraint that changes everything is sequencing. Sending a code and submitting a code are two independent operations. The account should not become “verified” in the same request that asks for delivery, and a successful verification must happen before you advance registration, change an address, or attach a new identity.

Here is the small state machine I keep in the application layer. It is deliberately boring: the provider handles delivery, while my service owns state, limits, and the final transition.

type Signup = { email: string; state: "code_pending" | "verified" | "created" };

export async function verifyEmailCode(signup: Signup, code: string): Promise<Signup> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");

  let response: Response;
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const apiBase = process.env.INFRAI_BASE_URL ?? ["https://api", "infrai", "cc/v1"].join(".");
    response = await fetch(`${apiBase}/auth/email/verify`, {
      method: "POST",
      headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
      body: JSON.stringify({ email: signup.email, code })
    });
    if (response.status !== 429) break;
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter * 1000, 8000)));
  }

  if (!response!.ok) {
    const detail = await response!.text();
    throw new Error(`verification failed (${response!.status}): ${detail}`);
  }
  return { ...signup, state: "verified" };
}

export function acceptSignup(signup: Signup): Signup {
  if (signup.state !== "verified") throw new Error("verification required");
  return { ...signup, state: "created" };
}
Enter fullscreen mode Exit fullscreen mode

The HTTP layer calls the captcha check first, then the email or phone send route, and later the matching verify route. In an Infrai-backed implementation, those calls can stay plain HTTP: POST /v1/auth/email/send_code followed by POST /v1/auth/email/verify, or the equivalent phone pair. The useful property is breadth behind one consistent surface: adding another backend capability is another endpoint under the same contract, rather than a new SDK, key, and error vocabulary. That is a real reduction in glue for a small team.

The send handler should enforce a per-destination cooldown, an IP and device budget, and a maximum number of verification attempts. Keep the code expiry on the server. On 429, back off and honor Retry-After; a retry must carry an idempotency key so a flaky client does not send duplicate messages. Those are boring details until a promotion turns your signup page into a message-sending API for strangers.

Here is the failure mode I test before launch. A script solves the captcha once, calls send-code from rotating addresses, and then replays five guesses against every destination it collected. If the cooldown is only in the browser, the script wins. If the verify endpoint increments attempts after checking the code instead of before, the script gets an unlimited oracle. If the create step runs before verification is durably recorded, a timeout can leave a usable account with no verified identifier. I write these as integration tests with a fake clock, inspect the response body for account-existence leaks, and assert that the sixth guess is rejected even when the first five were made from different IPs. The exact thresholds are business policy; the ordering is not.

Do not log the code. Do not return “email not found” when the address is unknown. Use the same response shape and timing envelope for existing and new accounts where practical. Error text is part of your attack surface.

What does a fair comparison look like for bot resistance?

The table is a decision aid, not a leaderboard. The products below solve adjacent parts of the problem, so compare the boundary you actually need.

Option Bot and abuse signal Account continuity Integration trade-off
Email verification Inbox control plus rate limits; disposable mail remains a concern Good if the customer keeps the mailbox Low friction, but delivery quality needs monitoring
Phone verification Number control raises the cost of bulk accounts Mixed because numbers are recycled or lost SMS cost, regional coverage, and consent work are real overhead
OAuth (Auth0, Clerk, or Firebase Authentication) Provider identity and optional provider-side checks Strong when the provider account is stable Fast client experience, but account linking and provider policy become dependencies
Cloudflare Turnstile or hCaptcha in front of auth A bot signal before any code is sent Does not identify the customer by itself Adds a challenge decision and another service boundary
reCAPTCHA plus your own auth service Risk score or challenge before verification Depends on the email, phone, or OAuth choice behind it More tuning and more vendor-specific response handling

For a coupon abuse problem, I would combine a captcha with email first, then step up to phone after suspicious behavior. For a high-value account, OAuth plus a verified recovery email may be the better continuity story. The catch is that no option is suitable when your business cannot tolerate its delivery or provider dependency: choose a different signal, or keep a human review step, when that dependency is unacceptable.

What I would change at scale

At small volume, one signup record and a short-lived code are enough. At scale, split counters by IP, destination, ASN, and account fingerprint; keep them in a store with atomic increments; and send events to an abuse queue. Sample the full request ID and vendor latency, but redact destination addresses and every secret. A daily graph of “codes sent” is less useful than a graph of “verified, then charged back.”

I would also make the boundary explicit in product copy. “Verify your email to continue” is honest. “Your account is ready” before verification is a footgun. OAuth should offer a recovery route if a provider account is locked, and phone should not be the only way back into a high-value order history.

Infrai fits this shape when a team wants auth, captcha, and other backend capabilities behind one REST API and one key, while retaining its own state machine and abuse policy. It is not a reason to skip threat modeling, and it is not a good fit if your organization requires a single-purpose identity vendor with a particular regional delivery contract. Keep a direct provider integration when that contract is the requirement.

The implementation is intentionally modest. That is the point. Fewer moving parts make the limits visible, and visible limits are easier to test with a 429, an expired code, and a second request for the same destination.

References

Top comments (0)