DEV Community

pengspirit
pengspirit

Posted on • Originally published at dev.to

Your OTP regex assumes six digits. Supabase magic links don't.

Sign-in worked flawlessly in dev. Then a real user pasted a real code and got "invalid format" — before the code ever reached Supabase. The credential was fine. My regex was wrong. Here's the one-line assumption that broke auth for every human who wasn't me.

I run a Discord-native Company Brain. Teams /save docs and /ask grounded answers; access is gated by a magic-link claim that emails a one-time code. Standard GoTrue OTP flow. The client shows a box, you paste the code, the server verifies it. Boring — which is exactly what auth should be.

The bug: a six-digit assumption in a validation guard

The claim handler did a cheap client-side sanity check before calling verifyOtp:

// The bug. Looks reasonable. Rejects every real code.
const OTP = /^\d{6}$/;

function normalize(input: string): string {
  const code = input.trim();
  if (!OTP.test(code)) throw new Error("Enter the 6-digit code from your email.");
  return code;
}
Enter fullscreen mode Exit fullscreen mode

Every OTP tutorial uses \d{6}. Every code demo shows six digits. So I typed six digits into the test and it passed. In dev I was generating my own codes and never actually reading the email.

Supabase's GoTrue emits an eight-digit code on this project. ^\d{6}$ rejects eight digits outright. The user's perfectly valid credential got thrown out by my own front door with a lie for an error message — "enter the 6-digit code" when the email plainly showed eight.

Why it happens: OTP length is a setting, not a constant

The length of a GoTrue email OTP is configurable — GOTRUE_MAILER_OTP_LENGTH (Dashboard → Authentication → Email). It defaults to six in many setups and to eight in others depending on when and how the project was provisioned. The number in the tutorial is that author's project setting, not a property of OTPs.

Hardcoding 6 couples your client to a server config you don't control and might change. Bump the length for security later and every client silently starts rejecting valid codes. No error in your logs — the rejection happens before the request leaves the browser.

The fix: the client guard must never be stricter than the issuer

A format check on a security token is a UX affordance, not a security control. Its only job is catching "you pasted your grocery list" before a round-trip. The real validity check is verifyOtp on the server — that's the authority. So the client regex should be loose: wide enough to never reject a real code, tight enough to skip an obviously empty box.

// Loose format guard. Supabase is the authority on validity — verifyOtp decides.
// Accept any 6–10 digit code so a server-side length change never breaks the client.
const OTP = /^\d{6,10}$/;

function normalize(input: string): string {
  // Users paste from an email client: trailing newline, stray spaces, a stray dash.
  const code = input.replace(/\D/g, "");
  if (!OTP.test(code)) throw new Error("Enter the code from your email.");
  return code;
}

// runnable check — the exact cases that bit me
function demo() {
  console.assert(normalize("12345678") === "12345678", "8-digit must pass");
  console.assert(normalize(" 1234 5678 \n") === "12345678", "strip paste noise");
  console.assert(normalize("123456") === "123456", "6-digit still passes");
  let threw = false;
  try { normalize("hello"); } catch { threw = true; }
  console.assert(threw, "non-digits must reject");
  console.log("ok");
}
demo();
Enter fullscreen mode Exit fullscreen mode

Two things doing the work:

  • \d{6,10} instead of \d{6}. A range absorbs whatever length GoTrue is configured for, today or after a future bump. I don't have to redeploy the client to match a server setting.
  • replace(/\D/g, "") instead of trim(). People don't retype the code, they paste it — straight out of Gmail with a trailing newline, a leading space, sometimes a soft-wrap dash. Stripping every non-digit is more honest than trimming the ends, and it's what the user meant.

Then let the server be the authority:

const { error } = await supabase.auth.verifyOtp({
  email,
  token: normalize(input),   // loose format guard already ran
  type: "email",
});
// verifyOtp is the real check: wrong code, expired code, wrong length — all rejected here,
// server-side, with a signal you can actually trust and log.
Enter fullscreen mode Exit fullscreen mode

The general rule

Any time the client validates a token the server issues, the client's check must be a superset of what the server accepts — never a subset. A guard stricter than the issuer doesn't add security; it manufactures false rejections of valid credentials, and it does it silently, before anything reaches a log you'd look at.

I found this the expensive way: a working sign-in for exactly one person (me), and a "the code doesn't work" report from everyone else. The fix was five characters — {6} to {6,10} — plus a normalize that respects how people actually paste.

Takeaways

  • OTP length is a server setting (GOTRUE_MAILER_OTP_LENGTH), not a constant. Don't hardcode 6 from a tutorial.
  • Client format checks are UX, not security. Keep them looser than the issuer; verifyOtp is the authority.
  • A guard stricter than the issuer rejects valid credentials silently — the worst kind of bug, because nothing errors on your side.
  • Users paste, they don't type. Strip non-digits, don't just trim().

Boring auth is good auth — but boring means the failure modes hide in the five characters you copied without reading. That's the tax on running a real product for real users, which is the whole bet behind Acortia.

Top comments (0)