비트베이크

Implementing Phone Verification in Flutter MVP in 5 Minutes (No Paperwork Required)

2026-03-20T01:04:06.346Z

FLUTTER-MVP-SMS

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 http package
  • 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_autofill or sms_autofill package 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

비트베이크에서 광고를 시작해보세요

광고 문의하기

다른 글 보기

2026-06-16T05:01:55.625Z

2026 다이소 여름 신상/인기템! 시원한 여름 꿀템 총정리

2026년 다이소 여름 신상부터 인기 쿨링템, 장마철 필수품, 홈캉스 아이템까지! 가성비 넘치는 다이소 여름 꿀템으로 시원하고 쾌적한 여름을 준비하는 완벽 가이드.

2026-06-16T05:01:31.367Z

지속 가능한 국내 워케이션: 2026년 숨은 보석 여행지

2026년 국내 워케이션 트렌드는 지속가능한 여행과 만납니다. 디지털 디톡스, 친환경 숙소, 로컬 체험을 통해 몸과 마음을 치유하고 지역 경제 활성화에 기여하는 숨은 명소 3곳을 소개합니다. 지금 바로 나만의 지속 가능한 워케이션을 계획해보세요!

2026-06-16T05:01:30.087Z

2026년 최신 의학 트렌드: AI와 정밀의료로 여는 초개인화 건강관리

2026년, AI와 정밀의료가 이끄는 초개인화 건강관리 시대가 열렸습니다. 딥러닝 기반 진단, 유전체 맞춤 치료, 웨어러블 및 디지털 치료제가 일상 속 건강을 혁신합니다. 미래 의학의 도전 과제와 현명한 건강 관리법을 알아보세요.

2026-06-16T05:01:16.613Z

2026 가을/겨울 출산준비물: 신생아 육아템 필수템 총정리

2026년 가을/겨울 출산을 앞둔 예비맘들을 위한 완벽 가이드! 최신 트렌드를 반영한 신생아 육아템 필수템부터 대형 육아용품 비교, 스마트한 케어 및 수유 용품, 쌀쌀한 날씨 대비 아기옷, 그리고 알뜰 구매 팁까지 모든 출산준비물을 총정리했습니다.

서비스

피드자주 묻는 질문고객센터

문의

비트베이크

레임스튜디오 | 사업자 등록번호 : 542-40-01042

경기도 남양주시 와부읍 수례로 116번길 16, 4층 402-제이270호

트위터인스타그램네이버 블로그