DEV Community

Cover image for How a 6/8-Digit Verification Code Inconsistency Locked Out 30% of Our Users
郑伟光
郑伟光

Posted on

How a 6/8-Digit Verification Code Inconsistency Locked Out 30% of Our Users

It started with a message I'll never forget:

"Your app is broken. I've tried 5 times. The code from my email doesn't work. Fix it or I'm uninstalling."

Four in the morning. One angry user. I stared at my phone, half-asleep, convinced it was user error. Maybe they were typing too fast. Maybe they had fat-fingered a digit. I sent the standard reply — "Please double-check the code and try again. Make sure you're copying the latest email." — and rolled over.

Two days later, our Play Console reviews told a different story. Fifteen one-star ratings. All the same complaint: "Can't verify email."

That was the moment I realized: this wasn't user error. Something was fundamentally broken in our verification pipeline — and it had been silently bleeding users for weeks.


What Is ScriptFlow?

Before diving into the bug, let me set the stage.

ScriptFlow AI (灵创) is a Flutter-based mobile app that uses AI to help content creators generate video scripts, storyboards, and creative briefs. Think of it as "ChatGPT meets a screenwriting assistant." The backend runs on Supabase, with DeepSeek-chat powering the AI generation, and email delivery is handled by Resend SMTP.

At the time of this story, we were in Google Play closed testing with roughly 130,000 cumulative downloads across our tester base. Registration was simple: enter your email, receive a verification code, type it in, done.

The auth flow looked like this:

User → Enter Email → Supabase Auth sends code → User checks inbox → Types code → Verified ✓
Enter fullscreen mode Exit fullscreen mode

Clean. Standard. Nothing exotic. Which is exactly why the bug was so hard to spot.


The Symptoms

Here's what users were actually experiencing:

  • "I never received the code." — Some users reported the email never arrived.
  • "The code is invalid." — Others got the email but the code was rejected every time.
  • "I tried the new code and it still doesn't work." — Resending didn't help either.

At first glance, this looked like a classic deliverability problem. Maybe our emails were going to spam? Maybe Resend's SMTP relay had a hiccup?

But digging into the Supabase Auth logs, I found something puzzling. Users who successfully verified their accounts had one thing in common: their verification codes were always 6 digits long. Users who failed? Their codes were 8 digits.

Wait — why were there two different code lengths in the same app?


The Investigation

I started tracing the code path from end to end.

Step 1: The Flutter Client

Our Flutter app's verification screen accepted a pin_code field with no explicit length validation. The widget was a standard 6-digit PIN input:

PinCodeTextField(
  length: 6,
  onCompleted: (code) {
    verifyEmail(code);
  },
)
Enter fullscreen mode Exit fullscreen mode

The widget expected exactly 6 digits. If the code was 8 digits, the user would type 6, hit submit, and the server would reject it because it was too short. If a particularly determined user tried to paste all 8 digits, the text field simply clipped the last two.

Step 2: The Supabase Auth Schema

This is where things got interesting. Supabase's auth schema has a confirmation_token or email_otp mechanism. By default, Supabase generates 6-digit OTP codes. But here's the kicker — we had customized our auth flow months earlier and, at some point, a configuration change (or lack thereof) caused the generated token length to fluctuate between 6 and 8 characters depending on which API endpoint was hit.

I traced it to two competing settings:

Configuration Source Code Length
Supabase Dashboard Auth Settings (default) 6 digits
Custom email template via Resend SMTP 8 characters (alphanumeric)

The Supabase Dashboard was configured to send 6-digit numeric OTPs. But our custom Resend email template — which we had set up when migrating away from Twilio — was generating its own token as an 8-character alphanumeric string and embedding it in the email body.

The result? A user might receive an email with an 8-character code from the Resend template, but Supabase was expecting the 6-digit code it had generated internally. The two tokens didn't match. The user typed in the wrong code. Verification failed.

Step 3: The Email Template

I opened the Resend email template and found this:

<p>Your verification code is: <strong>{{ .TokenHash }}</strong></p>
Enter fullscreen mode Exit fullscreen mode

The TokenHash variable was being populated by a custom edge function that generated a local token — completely independent of Supabase's built-in OTP system. Two separate token generators. Two different formats. Zero synchronization.


The Root Cause

In one sentence: the frontend expected 6 digits, the backend generated 6 digits, but the email template was injecting an 8-character token from a parallel code path that was never validated against either.

Here's a simplified diagram of what was happening:

                    ┌──────────────────┐
                    │  Supabase Auth    │
                    │  generates 6-digit│
                    │  OTP               │
                    └────────┬─────────┘
                             │
                    ┌────────▼─────────┐
                    │  Custom Edge      │
                    │  Function         │
                    │  generates 8-char │
                    │  token            │
                    └────────┬─────────┘
                             │
                    ┌────────▼─────────┐
                    │  Resend SMTP      │
                    │  Email Template   │
                    │  shows 8-char     │
                    │  token ❌          │
                    └────────┬─────────┘
                             │
                    ┌────────▼─────────┐
                    │  User types       │
                    │  6-digit widget   │
                    │  → mismatch ❌    │
                    └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

It was a classic "two sources of truth" problem. The token that Supabase generated and expected was never the token the user saw in their inbox.


The Fix

The fix was conceptually simple but required touching multiple layers:

1. Remove the Custom Token Generation

We killed the custom edge function that was generating the 8-character token. There was no reason for it to exist — Supabase already handles OTP generation securely.

// BEFORE (broken)
const token = generateCustomToken(8); // 8-char alphanumeric
await resend.emails.send({
  to: email,
  subject: 'Verify your email',
  html: `<p>Your code: <strong>${token}</strong></p>`,
});

// AFTER (fixed)
// Use Supabase's built-in email OTP — no custom token needed
await supabase.auth.signInWithOtp({
  email,
  options: {
    emailRedirectTo: 'scriptflow://verify',
  },
});
Enter fullscreen mode Exit fullscreen mode

2. Unify the Email Template

We rewrote the Resend email template to use Supabase's native OTP variable instead of a custom token:

<p>Your verification code is: <strong>{{ .Token }}</strong></p>
<p>This code expires in 10 minutes.</p>
Enter fullscreen mode Exit fullscreen mode

3. Enforce 6-Digit Validation on the Client

As a defense-in-depth measure, we added explicit length validation:

Future<void> verifyEmail(String code) async {
  if (code.length != 6) {
    showError('Please enter all 6 digits of the code.');
    return;
  }
  // ... proceed with verification
}
Enter fullscreen mode Exit fullscreen mode

4. Add Monitoring

We added a simple logging hook to track verification success rates:

// Supabase Auth Hook
const { data, error } = await supabase.auth.verifyOtp({
  email,
  token,
  type: 'email',
});

if (error) {
  logError('verify_failed', {
    email,
    errorCode: error.status,
    tokenLength: token.length, // critical metric
  });
}
Enter fullscreen mode Exit fullscreen mode

This gave us visibility into whether failed verifications correlated with token length — something we should have had from day one.


The Results

Within 24 hours of deploying the fix:

Metric Before After
Email verification success rate ~70% ~98%
Daily successful sign-ups ~180 ~260
1-star reviews mentioning "verify" 15/week 0/week

Chart placeholder: A dual-bar chart showing "Verification Success Rate" and "Daily Sign-ups" before and after the fix. Before: blue bars at ~70% / ~180. After: green bars at ~98% / ~260. Title: "Impact of Unifying Verification Code Format."

That ~30% gap between before and after? Those weren't users who didn't want to use our app. They were users who tried — sometimes five or six times — and gave up because we failed them on the very first interaction.

Chart placeholder: A line chart titled "Daily Verification Failures Over 8 Weeks." Week 1-5 shows a flat ~30% failure rate with occasional spikes. Week 6 has a sharp cliff drop — from ~30% to ~2%. An annotation marker on Week 6 reads "Fix deployed: unified 6-digit code."


What I Learned

1. "Boring Infrastructure" Is Where Users Leave

Everyone talks about UX polish — smooth animations, beautiful onboarding screens, delightful micro-interactions. But none of that matters if the user can't even get past the email verification step. The most boring, unsexy part of your stack (auth) is also the most critical.

2. Two Sources of Truth Always Break

The moment we had a custom token generator and Supabase's built-in OTP running in parallel, the system was doomed. It wasn't a matter of if it would break — it was when. Always ask yourself: "Is there exactly one place where this value is generated, and exactly one place where it's validated?"

3. Observability Isn't Optional

We went weeks without realizing 30% of our sign-ups were failing because we had no monitoring on verification success rates. A simple metric — verification_success_rate — would have caught this within hours, not weeks. If you're building anything with a sign-up flow, instrument it from day one.

4. User Complaints Are Leading Indicators

I dismissed that 4 AM message as user error. It wasn't. When multiple users report the same issue, even if it seems implausible, trust the pattern. Aggregate complaints are your most honest bug tracker.


Closing Thoughts

This bug cost us thousands of potential users, a pile of negative Play Store reviews, and more than a few sleepless nights. All because of a 2-character difference in a verification code.

If you're building an app with email verification — whether on Supabase, Firebase, Auth0, or a custom solution — do yourself a favor: go check your token generation pipeline right now. Make sure there's one source of truth. Make sure the frontend, backend, and email template agree on the format. And set up a dashboard that shows you verification_success_rate in real time.

Have you ever been burned by a "trivial" bug that turned out to be anything but? I'd love to hear your story in the comments.


This post is an entry for the DEV Summer Bug Smash 2026 contest in the **Smash Stories* category. Bug smashed, lessons learned, users recovered.*

Top comments (2)

Collapse
 
spydon profile image
Lukas Klingsbo

Thanks for sharing!
I'm maintaining Supabase's Flutter SDK so in the beginning of the article I was worried that there might be something that was wrong in there. 😅
If you have any feedback on Supabase's Flutter SDK feel free to reach out, we're just about to start with version 3 of it.

Collapse
 
vollos profile image
Pon

Wild that it bled for weeks before the pattern showed — one angry user at 4am reads as fat-fingering until fifteen Play reviews stack up, because a split like "success always 6 digits, failure always 8" only surfaces in aggregate. The root that jumps out: two systems were both minting codes. The moment the Resend template issued its own token, Supabase was validating a code it never generated. The bit I can't tell from here is why the token got minted app-side at all — did moving to Resend force you to generate it yourself, or could Supabase's OTP have dropped straight into the custom email and the template was just doing its own thing?