{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreigsmyayqyvp6ihm2angfyt4n7t2isynyvrl622xdjz7zxckshqn7e",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mplaxpmywcl2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreid6mts4hdyp42ik4d4m67qvoxmfcyqvov7nmvv7ow3mjnckt62jl4"
},
"mimeType": "image/webp",
"size": 67160
},
"path": "/incultnitollc/your-otp-regex-assumes-six-digits-supabase-magic-links-dont-33i1",
"publishedAt": "2026-07-01T09:38:23.000Z",
"site": "https://dev.to",
"tags": [
"supabase",
"auth",
"typescript",
"security",
"Acortia"
],
"textContent": "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.\n\nI 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.\n\n## The bug: a six-digit assumption in a validation guard\n\nThe claim handler did a cheap client-side sanity check before calling `verifyOtp`:\n\n\n\n // The bug. Looks reasonable. Rejects every real code.\n const OTP = /^\\d{6}$/;\n\n function normalize(input: string): string {\n const code = input.trim();\n if (!OTP.test(code)) throw new Error(\"Enter the 6-digit code from your email.\");\n return code;\n }\n\n\nEvery 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.\n\nSupabase'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.\n\n## Why it happens: OTP length is a setting, not a constant\n\nThe 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.\n\nHardcoding `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.\n\n## The fix: the client guard must never be stricter than the issuer\n\nA 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.\n\n\n\n // Loose format guard. Supabase is the authority on validity — verifyOtp decides.\n // Accept any 6–10 digit code so a server-side length change never breaks the client.\n const OTP = /^\\d{6,10}$/;\n\n function normalize(input: string): string {\n // Users paste from an email client: trailing newline, stray spaces, a stray dash.\n const code = input.replace(/\\D/g, \"\");\n if (!OTP.test(code)) throw new Error(\"Enter the code from your email.\");\n return code;\n }\n\n // runnable check — the exact cases that bit me\n function demo() {\n console.assert(normalize(\"12345678\") === \"12345678\", \"8-digit must pass\");\n console.assert(normalize(\" 1234 5678 \\n\") === \"12345678\", \"strip paste noise\");\n console.assert(normalize(\"123456\") === \"123456\", \"6-digit still passes\");\n let threw = false;\n try { normalize(\"hello\"); } catch { threw = true; }\n console.assert(threw, \"non-digits must reject\");\n console.log(\"ok\");\n }\n demo();\n\n\nTwo things doing the work:\n\n * **`\\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.\n * **`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_.\n\n\n\nThen let the server be the authority:\n\n\n\n const { error } = await supabase.auth.verifyOtp({\n email,\n token: normalize(input), // loose format guard already ran\n type: \"email\",\n });\n // verifyOtp is the real check: wrong code, expired code, wrong length — all rejected here,\n // server-side, with a signal you can actually trust and log.\n\n\n## The general rule\n\nAny 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.\n\nI 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.\n\n## Takeaways\n\n * **OTP length is a server setting** (`GOTRUE_MAILER_OTP_LENGTH`), not a constant. Don't hardcode `6` from a tutorial.\n * **Client format checks are UX, not security.** Keep them looser than the issuer; `verifyOtp` is the authority.\n * **A guard stricter than the issuer rejects valid credentials silently** — the worst kind of bug, because nothing errors on your side.\n * **Users paste, they don't type.** Strip non-digits, don't just `trim()`.\n\n\n\nBoring 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.",
"title": "Your OTP regex assumes six digits. Supabase magic links don't."
}