Implementing Phone Verification in Flutter MVP in 5 Minutes (No Paperwork Required)
2026-03-20T01:04:06.346Z
![]()
Implementing Phone Verification in Flutter MVP in 5 Minutes (No Paperwork Required)
The Paperwork Problem Every Developer Hates
You're building a side project. You need SMS verification. You find an SMS API provider, and they ask for... business registration documents? A signed usage certificate? Government-issued ID verification?
For a weekend project, this is a dealbreaker.
Firebase Phone Auth is an option, but setting up a Firebase project, registering SHA-1 keys, configuring Google Cloud Console, and dealing with platform-specific quirks takes more time than building the actual feature.
In this guide, you'll implement complete phone verification in Flutter using just two REST API endpoints — no Firebase, no paperwork, no complex configuration. Copy the code, plug in your API key, and ship.
What You'll Learn
- Building an SMS verification service class with Dart's
httppackage - Creating a polished OTP input UI with
pin_code_fields - Implementing countdown timers and resend logic
- Error handling and security best practices for production
Step 1: Project Setup
Add the required packages to your Flutter project:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
pin_code_fields: ^8.0.1
flutter pub get
That's it for dependencies. No Firebase configuration files, no google-services.json, no GoogleService-Info.plist.
Step 2: Build the SMS Auth Service
The entire SMS verification flow requires only two endpoints:
- POST /send — Send a verification code to a phone number
- POST /verify — Verify the code the user enters
Here's the complete service class:
// lib/services/auth_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
class SmsAuthService {
static const String _baseUrl = 'https://api.easyauth.io/v1';
final String _apiKey;
SmsAuthService(this._apiKey);
/// Send verification code to phone number
Future> sendCode(String phoneNumber) async {
final response = await http.post(
Uri.parse('$_baseUrl/send'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_apiKey',
},
body: jsonEncode({
'phone_number': phoneNumber,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw SmsAuthException(
'Failed to send code',
response.statusCode,
response.body,
);
}
}
/// Verify the code entered by user
Future> verifyCode({
required String phoneNumber,
required String code,
}) async {
final response = await http.post(
Uri.parse('$_baseUrl/verify'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_apiKey',
},
body: jsonEncode({
'phone_number': phoneNumber,
'code': code,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw SmsAuthException(
'Verification failed',
response.statusCode,
response.body,
);
}
}
}
class SmsAuthException implements Exception {
final String message;
final int statusCode;
final String responseBody;
SmsAuthException(this.message, this.statusCode, this.responseBody);
@override
String toString() => 'SmsAuthException($statusCode): $message';
}
> Key insight: The entire backend integration is ~60 lines of Dart. No SDK installation, no initialization ceremony, no platform-specific setup.
Step 3: Phone Number Input Screen
// lib/screens/phone_input_screen.dart
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import 'otp_verify_screen.dart';
class PhoneInputScreen extends StatefulWidget {
const PhoneInputScreen({super.key});
@override
State createState() => _PhoneInputScreenState();
}
class _PhoneInputScreenState extends State {
final _phoneController = TextEditingController();
final _authService = SmsAuthService('YOUR_API_KEY');
bool _isLoading = false;
Future _sendVerificationCode() async {
final phone = _phoneController.text.trim();
if (phone.isEmpty || phone.length < 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a valid phone number')),
);
return;
}
setState(() => _isLoading = true);
try {
await _authService.sendCode(phone);
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OtpVerifyScreen(
phoneNumber: phone,
authService: _authService,
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to send code: $e')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Phone Verification')),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Enter your phone number',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text('We\'ll send you a verification code via SMS'),
const SizedBox(height: 24),
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
hintText: '+1 (555) 123-4567',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _sendVerificationCode,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Send Verification Code'),
),
],
),
),
);
}
}
Step 4: OTP Verification Screen with Timer
Using the pin_code_fields package, we create a polished 6-digit OTP input with auto-submit, countdown timer, and resend functionality:
// lib/screens/otp_verify_screen.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pin_code_fields/pin_code_fields.dart';
import '../services/auth_service.dart';
class OtpVerifyScreen extends StatefulWidget {
final String phoneNumber;
final SmsAuthService authService;
const OtpVerifyScreen({
super.key,
required this.phoneNumber,
required this.authService,
});
@override
State createState() => _OtpVerifyScreenState();
}
class _OtpVerifyScreenState extends State {
String _otpCode = '';
bool _isLoading = false;
int _remainingSeconds = 180; // 3-minute timer
Timer? _timer;
@override
void initState() {
super.initState();
_startTimer();
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_remainingSeconds > 0) {
setState(() => _remainingSeconds--);
} else {
timer.cancel();
}
});
}
String get _timerText {
final m = _remainingSeconds ~/ 60;
final s = _remainingSeconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
Future _verifyCode() async {
if (_otpCode.length != 6) return;
setState(() => _isLoading = true);
try {
await widget.authService.verifyCode(
phoneNumber: widget.phoneNumber,
code: _otpCode,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Verification successful!'),
backgroundColor: Colors.green,
),
);
Navigator.popUntil(context, (route) => route.isFirst);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Verification failed: $e')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future _resendCode() async {
await widget.authService.sendCode(widget.phoneNumber);
setState(() => _remainingSeconds = 180);
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Enter Verification Code')),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Code sent to ${widget.phoneNumber}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Time remaining: $_timerText',
style: TextStyle(
color: _remainingSeconds < 30
? Colors.red
: Colors.grey[600],
fontSize: 14,
),
),
const SizedBox(height: 32),
PinCodeTextField(
appContext: context,
length: 6,
onChanged: (value) => _otpCode = value,
onCompleted: (_) => _verifyCode(),
pinTheme: PinTheme(
shape: PinCodeFieldShape.box,
borderRadius: BorderRadius.circular(8),
fieldHeight: 50,
fieldWidth: 45,
activeFillColor: Colors.white,
activeColor: Colors.blue,
selectedColor: Colors.blue,
inactiveColor: Colors.grey.shade300,
),
keyboardType: TextInputType.number,
animationType: AnimationType.fade,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _verifyCode,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Verify'),
),
const SizedBox(height: 8),
TextButton(
onPressed: _remainingSeconds == 0 ? _resendCode : null,
child: const Text('Resend Code'),
),
],
),
),
);
}
}
Complete Flow at a Glance
User → Enter phone number → POST /send → Receive SMS
→ Enter OTP code → POST /verify → Verified ✓
That's the entire architecture. Two screens, two API calls, zero platform configuration.
Firebase Phone Auth vs REST API: A Comparison
| Aspect | Firebase Phone Auth | REST API Approach | |--------|-------------------|-------------------| | Initial Setup | Firebase project + SHA keys + Cloud Console | API key only | | Paperwork | None (but complex config) | None | | Time to Implement | 30-60 minutes | ~5 minutes | | Cost | 10 free/month, then $0.06/verification | ~$0.01-0.02/verification | | Customization | Limited | Full control | | Platform Dependencies | Firebase SDK required | HTTP requests only | | Bundle Size Impact | +2-5 MB | Negligible |
Production Tips & Security Best Practices
1. Never Hardcode API Keys
// ❌ Don't do this
const apiKey = 'sk-live-xxxxx';
// ✅ Use compile-time environment variables
const apiKey = String.fromEnvironment('SMS_API_KEY');
flutter run --dart-define=SMS_API_KEY=your-key-here
For production builds, consider a backend proxy that holds the API key server-side.
2. Phone Number Validation
/// Validate phone numbers before sending to API
bool isValidPhone(String phone) {
// International format
return RegExp(r'^\+?[1-9]\d{6,14}$').hasMatch(phone);
}
/// Korean phone number validation
bool isValidKoreanPhone(String phone) {
return RegExp(r'^01[016789]\d{7,8}$').hasMatch(phone);
}
3. Client-Side Rate Limiting
Prevent users from spamming the send button:
DateTime? _lastSendTime;
bool get _canResend {
if (_lastSendTime == null) return true;
return DateTime.now().difference(_lastSendTime!) >
const Duration(seconds: 60);
}
4. OTP Auto-Fill Support
For a better user experience, consider adding SMS auto-fill:
- Android: Use the
otp_autofillorsms_autofillpackage which leverages the SMS Retriever API - iOS: OTP auto-fill works automatically via the keyboard suggestion bar when using
TextInputType.number
5. Robust Error Handling
try {
await authService.sendCode(phone);
} on SocketException {
showError('No internet connection');
} on TimeoutException {
showError('Request timed out. Please try again.');
} on SmsAuthException catch (e) {
if (e.statusCode == 429) {
showError('Too many attempts. Please wait.');
} else {
showError('Something went wrong. Please try again.');
}
}
Wrapping Up
Adding phone verification to your Flutter MVP doesn't have to be a multi-hour ordeal. With a simple REST API approach, you can have SMS verification running in under 5 minutes — no Firebase project setup, no SHA key registration, no platform-specific configuration files.
If you're looking for an SMS verification API that you can start using immediately without any paperwork or business registration documents, check out EasyAuth. You get 10 free verifications on signup to test your integration, and ongoing pricing starts at just $0.01-0.02 per verification — significantly cheaper than most alternatives. It's designed specifically for developers who want to ship fast and focus on what matters: building their product.
Stop wrestling with infrastructure. Start shipping features.
Tags: #Flutter #SMSVerification #OTP #MVP #MobileAuth #EasyAuth
비트베이크에서 광고를 시작해보세요
광고 문의하기