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;
}
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 oftrim(). 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 hardcode6from a tutorial. - Client format checks are UX, not security. Keep them looser than the issuer;
verifyOtpis 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