{
  "$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."
}