External Publication
Visit Post

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

DEV Community [Unofficial] July 1, 2026
Source

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;
}

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();

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.

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.

Discussion in the ATmosphere

Loading comments...