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
Create the verification_codes Firestore collection
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.
Build the sendVerificationCode Cloud Function
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.
1// functions/index.js2const functions = require('firebase-functions');3const admin = require('firebase-admin');4const sgMail = require('@sendgrid/mail');5const crypto = require('crypto');67admin.initializeApp();89exports.sendVerificationCode = functions.https.onCall(async (data, context) => {10 if (!context.auth) {11 throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');12 }1314 const email = context.auth.token.email;15 if (!email) {16 throw new functions.https.HttpsError('invalid-argument', 'No email on account');17 }1819 const docId = email.replace(/\./g, ',');20 const db = admin.firestore();21 const ref = db.collection('verification_codes').doc(docId);2223 // Rate limit: one code per 60 seconds24 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 }3435 const code = crypto.randomInt(100000, 999999).toString();36 const expiresAt = new Date(Date.now() + 10 * 60 * 1000);3738 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 });4647 const apiKey = process.env.SENDGRID_API_KEY;48 sgMail.setApiKey(apiKey);4950 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 });5657 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.
Build the 6-digit OTP input UI in FlutterFlow
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.
Build the verifyCode Cloud Function with expiry and attempt limiting
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.
1exports.verifyCode = functions.https.onCall(async (data, context) => {2 if (!context.auth) {3 throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');4 }56 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);1112 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 }1617 const { code: storedCode, expiresAt, attempts, verified } = doc.data();1819 if (verified) return { success: true, alreadyVerified: true };2021 if (expiresAt.toDate() < new Date()) {22 throw new functions.https.HttpsError('deadline-exceeded', 'Code expired. Request a new one.');23 }2425 if (attempts >= 5) {26 throw new functions.https.HttpsError('resource-exhausted', 'Too many attempts. Request a new code.');27 }2829 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 }3637 await ref.update({ verified: true });38 await admin.auth().setCustomUserClaims(context.auth.uid, { emailVerified: true });3940 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.
Add a rate-limited Resend Code button in FlutterFlow
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
1// Custom Action: submitOTPCode2// Called when user taps Verify after entering all 6 digits3import 'package:cloud_functions/cloud_functions.dart';4import 'package:firebase_auth/firebase_auth.dart';56Future<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';1516 if (code.length != 6 || int.tryParse(code) == null) {17 return {'success': false, 'error': 'Please enter all 6 digits'};18 }1920 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>;2425 if (data['success'] == true) {26 // Refresh the ID token so the new custom claim is available immediately27 await FirebaseAuth.instance.currentUser?.getIdToken(true);28 return {'success': true};29 }3031 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation