Skip to main content
RapidDev - Software Development Agency
flutterflow-tutorials

How to Build a Custom Email Verification System in FlutterFlow

Build a custom email OTP system: a Cloud Function generates a 6-digit code, stores it in Firestore with a 10-minute expiry timestamp, and sends it via SendGrid. The user enters the code in six individual TextField widgets. A second Cloud Function validates the code against Firestore, checks the expiry, and marks the user as verified. Always add rate limiting to the Resend Code button.

What you'll learn

  • How to generate and store a 6-digit OTP code in Firestore with an expiry timestamp
  • How to send verification emails via SendGrid from a Firebase Cloud Function
  • How to build a 6-digit OTP input UI with auto-advance TextFields in FlutterFlow
  • How to implement rate limiting on the Resend Code button
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner10 min read35-50 minFlutterFlow Free+ (Cloud Functions require Firebase Blaze plan)March 2026RapidDev Engineering Team
TL;DR

Build a custom email OTP system: a Cloud Function generates a 6-digit code, stores it in Firestore with a 10-minute expiry timestamp, and sends it via SendGrid. The user enters the code in six individual TextField widgets. A second Cloud Function validates the code against Firestore, checks the expiry, and marks the user as verified. Always add rate limiting to the Resend Code button.

Custom OTP Email Verification vs Firebase's Default

Firebase Authentication includes a basic email verification link, but it does not give you control over the email design, the code format, or the verification UI. A custom OTP system lets you send a branded 6-digit code, verify it in-app without redirecting to a browser, and control expiry and retry logic. This tutorial builds the full system: Cloud Function for code generation and email sending, Firestore for secure code storage, a six-field OTP input in FlutterFlow, and a rate-limited Resend button.

Prerequisites

  • FlutterFlow project with Firebase Authentication and Firestore configured
  • Firebase Blaze billing plan for Cloud Functions
  • SendGrid account with a verified sender domain (free tier has 100 emails/day)
  • Basic understanding of FlutterFlow Custom Actions and Cloud Functions

Step-by-step guide

1

Create the verification_codes Firestore collection

In FlutterFlow's Firestore panel, create a collection named verification_codes. Each document uses the user's email address as the document ID (URL-encoded to replace dots with commas, since Firestore document IDs cannot contain periods). Fields: code (String — 6-digit number stored as a string), userId (String), expiresAt (Timestamp — set to 10 minutes from creation), attempts (Integer — count of failed verification attempts), and verified (Boolean). Using the email as the document ID means each user has at most one pending code, preventing accumulation of stale code documents.

Expected result: The verification_codes collection is visible in FlutterFlow's Firestore schema editor with all fields defined correctly.

2

Build the sendVerificationCode Cloud Function

Create a Firebase Cloud Function named sendVerificationCode. It accepts the authenticated user's email, generates a cryptographically random 6-digit code using crypto.randomInt(100000, 999999), stores it in Firestore with an expiresAt 10 minutes from now, and sends the email via SendGrid's API. Store your SendGrid API key as a Firebase Function secret. The function should check if a code was sent in the last 60 seconds and reject the request to prevent rapid resend abuse — this is your first layer of rate limiting at the server level.

functions/send_verification_code.js
1// functions/index.js
2const functions = require('firebase-functions');
3const admin = require('firebase-admin');
4const sgMail = require('@sendgrid/mail');
5const crypto = require('crypto');
6
7admin.initializeApp();
8
9exports.sendVerificationCode = functions.https.onCall(async (data, context) => {
10 if (!context.auth) {
11 throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');
12 }
13
14 const email = context.auth.token.email;
15 if (!email) {
16 throw new functions.https.HttpsError('invalid-argument', 'No email on account');
17 }
18
19 const docId = email.replace(/\./g, ',');
20 const db = admin.firestore();
21 const ref = db.collection('verification_codes').doc(docId);
22
23 // Rate limit: one code per 60 seconds
24 const existing = await ref.get();
25 if (existing.exists) {
26 const createdAt = existing.data().createdAt?.toDate();
27 if (createdAt && Date.now() - createdAt.getTime() < 60000) {
28 throw new functions.https.HttpsError(
29 'resource-exhausted',
30 'Please wait 60 seconds before requesting a new code'
31 );
32 }
33 }
34
35 const code = crypto.randomInt(100000, 999999).toString();
36 const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
37
38 await ref.set({
39 code,
40 userId: context.auth.uid,
41 expiresAt: admin.firestore.Timestamp.fromDate(expiresAt),
42 createdAt: admin.firestore.FieldValue.serverTimestamp(),
43 attempts: 0,
44 verified: false,
45 });
46
47 const apiKey = process.env.SENDGRID_API_KEY;
48 sgMail.setApiKey(apiKey);
49
50 await sgMail.send({
51 to: email,
52 from: 'noreply@yourapp.com',
53 subject: 'Your verification code',
54 html: `<p>Your code is: <strong style="font-size:24px;letter-spacing:4px">${code}</strong></p><p>Expires in 10 minutes.</p>`,
55 });
56
57 return { success: true };
58});

Expected result: Calling the sendVerificationCode function from FlutterFlow sends an email with a 6-digit code and writes the code to Firestore with a 10-minute expiry.

3

Build the 6-digit OTP input UI in FlutterFlow

On your email verification page, add a Row widget containing six TextField widgets. Set each TextField's maxLength to 1, keyboardType to Number, textAlign to Center, and width to 44px. Give each TextField a unique Page State variable: digit1 through digit6 (all Strings). In each TextField's onChange action, update its corresponding digit variable. When all six digits are filled, concatenate them into a full code string using string interpolation: digit1 + digit2 + digit3 + digit4 + digit5 + digit6. To create the auto-advance behavior (cursor moves to the next field when a digit is entered), you need a Custom Action that calls FocusScope.of(context).nextFocus() after each digit is entered.

Expected result: The OTP input shows six boxes, each accepting one number. Entering a digit automatically moves focus to the next box.

4

Build the verifyCode Cloud Function with expiry and attempt limiting

Create a second Cloud Function named verifyCode. It accepts the 6-digit code submitted by the user, reads the verification_codes document for their email, and performs three checks: the code has not expired (expiresAt > now), the number of failed attempts is under 5, and the submitted code matches. If all checks pass, set verified: true on the document and set a custom claim on the Firebase user with setCustomUserClaims({ emailVerified: true }). If the code is wrong, increment the attempts counter. If attempts reaches 5, invalidate the code by setting expiresAt to now to prevent brute-force guessing.

functions/verify_code.js
1exports.verifyCode = functions.https.onCall(async (data, context) => {
2 if (!context.auth) {
3 throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');
4 }
5
6 const { code } = data;
7 const email = context.auth.token.email;
8 const docId = email.replace(/\./g, ',');
9 const db = admin.firestore();
10 const ref = db.collection('verification_codes').doc(docId);
11
12 const doc = await ref.get();
13 if (!doc.exists) {
14 throw new functions.https.HttpsError('not-found', 'No code found. Request a new one.');
15 }
16
17 const { code: storedCode, expiresAt, attempts, verified } = doc.data();
18
19 if (verified) return { success: true, alreadyVerified: true };
20
21 if (expiresAt.toDate() < new Date()) {
22 throw new functions.https.HttpsError('deadline-exceeded', 'Code expired. Request a new one.');
23 }
24
25 if (attempts >= 5) {
26 throw new functions.https.HttpsError('resource-exhausted', 'Too many attempts. Request a new code.');
27 }
28
29 if (code !== storedCode) {
30 await ref.update({ attempts: admin.firestore.FieldValue.increment(1) });
31 if (attempts + 1 >= 5) {
32 await ref.update({ expiresAt: admin.firestore.Timestamp.fromDate(new Date()) });
33 }
34 throw new functions.https.HttpsError('invalid-argument', 'Incorrect code.');
35 }
36
37 await ref.update({ verified: true });
38 await admin.auth().setCustomUserClaims(context.auth.uid, { emailVerified: true });
39
40 return { success: true };
41});

Expected result: Submitting the correct 6-digit code marks the user as verified in Firestore and adds a custom claim to their Firebase Auth token. Wrong codes increment a counter and are rejected after 5 attempts.

5

Add a rate-limited Resend Code button in FlutterFlow

Add a Resend Code TextButton below the OTP input. This button must be disabled for 60 seconds after the last send to prevent spamming. In FlutterFlow, create a Page State variable named resendCooldownEnd (Integer, storing a Unix timestamp in milliseconds). When the page loads and when Send Code is tapped, set resendCooldownEnd to DateTime.now().millisecondsSinceEpoch + 60000. Use a Conditional Widget to show either a disabled 'Resend in Xs' button (when current time < resendCooldownEnd) or an active Resend button. Use a periodic Timer action to decrement a displayed countdown every second by updating a Page State variable named secondsRemaining.

Expected result: After requesting a code, the Resend button shows a 60-second countdown and is non-interactive. After the countdown, it becomes active again.

Complete working example

otp_verification_page_actions.dart
1// Custom Action: submitOTPCode
2// Called when user taps Verify after entering all 6 digits
3import 'package:cloud_functions/cloud_functions.dart';
4import 'package:firebase_auth/firebase_auth.dart';
5
6Future<Map<String, dynamic>> submitOTPCode({
7 required String digit1,
8 required String digit2,
9 required String digit3,
10 required String digit4,
11 required String digit5,
12 required String digit6,
13}) async {
14 final code = '$digit1$digit2$digit3$digit4$digit5$digit6';
15
16 if (code.length != 6 || int.tryParse(code) == null) {
17 return {'success': false, 'error': 'Please enter all 6 digits'};
18 }
19
20 try {
21 final callable = FirebaseFunctions.instance.httpsCallable('verifyCode');
22 final result = await callable.call({'code': code});
23 final data = result.data as Map<String, dynamic>;
24
25 if (data['success'] == true) {
26 // Refresh the ID token so the new custom claim is available immediately
27 await FirebaseAuth.instance.currentUser?.getIdToken(true);
28 return {'success': true};
29 }
30
31 return {'success': false, 'error': 'Verification failed'};
32 } on FirebaseFunctionsException catch (e) {
33 String message;
34 switch (e.code) {
35 case 'deadline-exceeded':
36 message = 'Code expired. Tap Resend to get a new one.';
37 break;
38 case 'resource-exhausted':
39 message = 'Too many attempts. Request a new code.';
40 break;
41 case 'invalid-argument':
42 message = 'Incorrect code. Please check and try again.';
43 break;
44 default:
45 message = 'Verification failed. Please try again.';
46 }
47 return {'success': false, 'error': message};
48 }
49}

Common mistakes when building a Custom Email Verification System in FlutterFlow

Why it's a problem: Not implementing rate limiting on the Resend Code button

How to avoid: Enforce a 60-second cooldown on the client (disabled button with countdown) AND in the Cloud Function (reject requests within 60 seconds of the previous code). Both layers are needed.

Why it's a problem: Storing the verification code in Firestore without an expiry timestamp

How to avoid: Always set an expiresAt timestamp (10-15 minutes from creation) in the Cloud Function and check it in the verifyCode function before accepting any code.

Why it's a problem: Using Math.random() to generate the 6-digit code

How to avoid: Use crypto.randomInt(100000, 999999) in Node.js Cloud Functions. This uses the OS's cryptographically secure random source.

Why it's a problem: Not limiting the number of verification attempts per code

How to avoid: Track an attempts counter on the verification_codes document. After 5 failed attempts, invalidate the code by setting expiresAt to the current time and require the user to request a new code.

Best practices

  • Enforce rate limiting at both the client UI layer (disabled button) and the Cloud Function layer (60-second server-side check).
  • Use crypto.randomInt() for code generation, not Math.random().
  • Set a 10-minute code expiry and a 5-attempt limit to prevent brute-force attacks.
  • Use the email address as the Firestore document ID so each user can only have one active code at a time.
  • After successful verification, force a Firebase Auth token refresh so the emailVerified custom claim is immediately available to Firestore security rules.
  • Delete verification_codes documents after successful verification using a Firestore TTL or explicit delete to keep the collection clean.
  • Log failed verification attempts with userId and timestamp to a separate security_events collection for fraud monitoring.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I am building a FlutterFlow app and need a custom 6-digit OTP email verification system using Firebase Cloud Functions and SendGrid. Explain the complete architecture: code generation, secure storage in Firestore with expiry, email sending, OTP input UI, and the validation Cloud Function.

FlutterFlow Prompt

Add a custom email OTP verification flow to my FlutterFlow app. Create a 6-box OTP input UI and wire it to a Cloud Function that validates the code against Firestore, checks expiry, and limits to 5 attempts. Add a Resend button with a 60-second cooldown timer.

Frequently asked questions

Why build a custom OTP system when Firebase Auth already has email verification?

Firebase's built-in email verification sends a link that opens a browser, which creates a poor experience for mobile apps. A custom 6-digit OTP stays entirely in-app, allows custom email branding, and gives you full control over expiry and retry policies.

How do I send emails from a Cloud Function without SendGrid?

You can use Nodemailer with Gmail (free but limited), Mailgun, Amazon SES, or Resend. The code structure is the same: set your API key as a Cloud Function secret, call the sending API with the recipient email and code, and handle the response.

What happens if the user changes email addresses?

Re-trigger the entire verification flow for the new email address. When the user updates their email in Firebase Auth, generate a new OTP for the new address and require them to verify it before the update takes effect.

How do I prevent someone from requesting codes for email addresses that do not belong to them?

Only send codes to the email address on the authenticated Firebase Auth account (context.auth.token.email). Never accept the email as a request parameter — always read it from the authenticated token.

Can I use SMS OTP instead of email?

Yes. Firebase Phone Authentication provides built-in SMS OTP for free. If you need a custom SMS flow, use Twilio Verify instead of SendGrid. The Cloud Function structure is identical — replace the SendGrid send call with a Twilio API call.

Does the custom OTP system work with FlutterFlow's Free plan?

The UI parts (OTP input, countdown timer) can be built on the Free plan. However, Cloud Functions require the Firebase Blaze (pay-as-you-go) plan. The Cloud Functions themselves are very cheap — typically under $1/month for most apps.

How do I make the six TextFields auto-advance when a digit is entered?

Create a Custom Action that calls FocusScope.of(context).nextFocus(). Trigger this action from each TextField's onChange event when the field has exactly one character. For the last field, call FocusScope.of(context).unfocus() and trigger the verify action automatically.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.