Flutter MRZ Scanner Tutorial: Read Passport & ID Card Data on Android and iOS

Machine Readable Zone (MRZ) scanning is a critical feature in identity verification, border control, hotel check-in, and know-your-customer (KYC) workflows. Passports and ICAO-compliant ID cards embed personal data in two or three lines of OCR-B printed text at the bottom of the data page — the MRZ. A mobile app that can decode this in real time cuts manual data-entry errors, speeds up workflows, and improves user experience.

This tutorial walks you through building a Flutter MRZ scanner for Android and iOS using the Dynamsoft MRZ Scanner SDK.

What you’ll build: A production-ready Flutter MRZ scanner app that reads Machine Readable Zone data from passports and ID cards in real time on Android and iOS, using the Dynamsoft MRZ Scanner SDK.

Key Takeaways

  • Flutter with the Dynamsoft MRZ Scanner SDK decodes TD1, TD2, and TD3 MRZ formats from passports and ID cards on both Android and iOS.
  • The SDK returns a structured MRZData object with parsed fields (name, nationality, date of birth, expiry date), eliminating the need to write manual OCR-B parse logic.
  • A minSdk of 21 (Android 5.0) and a physical device with a camera are required — MRZ scanning does not work on an emulator.
  • This architecture applies directly to production KYC, border control, and hotel check-in workflows.

Common Developer Questions

  • How do I build a Flutter MRZ scanner that reads passports on Android and iOS?
  • Why does the Flutter MRZ scanner fail or crash on an Android emulator?
  • How do I configure iOS camera permissions for a Flutter MRZ scanner app?

Demo Video: Flutter MRZ Scanner

Prerequisites

Requirement Version
Flutter SDK ≥ 3.8
Dart SDK ≥ 3.8
Android Studio or VS Code Latest
Xcode (for iOS builds) Latest
A Dynamsoft license key 30-day free trial

You also need a physical Android or iOS device — the MRZ scanner cannot run on an emulator because it requires a real camera.

How MRZ Formats Work: TD1, TD2, and TD3

The International Civil Aviation Organization (ICAO) defines three Travel Document formats under doc 9303:

  • TD1 — 3-line, 30 characters per line — used on national ID cards
  • TD2 — 2-line, 36 characters per line — used on some national ID cards
  • TD3 — 2-line, 44 characters per line — used on passports and travel documents

Each line encodes fields using a fixed positional schema with check digits. Manually implementing the parse logic is error-prone, which is why a dedicated SDK is the right choice.

Set Up Your Flutter MRZ Scanner Project

Create the Flutter Project

flutter create --org com.dynamsoft.flutter mrz_scanner
cd mrz_scanner

Add the Dynamsoft MRZ Scanner Dependency

Open pubspec.yaml and add the SDK under dependencies:

name: mrz_scanner
description: >-
  A production-ready Flutter MRZ scanner that reads passport and ID card data.
version: 1.0.0+1
publish_to: 'none'

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8
  dynamsoft_mrz_scanner_bundle_flutter: ^3.2.5000

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

Then fetch the packages:

flutter pub get

Understand the Project Architecture

The following structure is used in this project:

lib/
├── main.dart                  ← App bootstrap and Material 3 theme
├── config/
│   └── app_config.dart        ← License key and app-wide constants
├── models/
│   └── mrz_result_model.dart  ← View-model that wraps raw MRZData
├── screens/
│   ├── home_screen.dart       ← Landing page with instructions + scan CTA
│   └── result_screen.dart     ← Full-screen display of parsed MRZ fields
└── widgets/
    └── mrz_result_card.dart   ← Reusable card that renders one MRZ field row

This structure keeps each file focused, makes unit-testing individual pieces straightforward, and scales well when you add more features (history, export, dark-mode toggle, etc.).

Step 1: Configure the App License Key

Centralize the license key and any app-wide constants in a single file so they are easy to find and replace:

// lib/config/app_config.dart
class AppConfig {
  AppConfig._();

  /// Replace with your own Dynamsoft license key.
  /// https://www.dynamsoft.com/customer/license/trialLicense/?product=dcv&package=cross-platform
  static const String licenseKey =
      'LICENSE_KEY_HERE';

  static const String appName = 'MRZ Scanner';
}

Step 2: Build the MRZ Result View-Model

The raw MRZData object returned by the SDK contains all the data you need, but a view-model layer converts it into display-ready strings and a list of field records:

// lib/models/mrz_result_model.dart
import 'package:dynamsoft_mrz_scanner_bundle_flutter/dynamsoft_mrz_scanner_bundle_flutter.dart';

class MrzResultModel {
  MrzResultModel({required MRZData data}) : _data = data;

  final MRZData _data;

  String get fullName => '${_data.firstName} ${_data.lastName}'.trim();
  String get sex => _capitalize(_data.sex);
  String get age => _data.age.toString();
  String get documentType => _data.documentType;
  String get documentNumber => _data.documentNumber;
  String get issuingState => _data.issuingState;
  String get nationality => _data.nationality;
  String get dateOfBirth => _data.dateOfBirth;
  String get dateOfExpiry => _data.dateOfExpire;

  String _capitalize(String s) {
    if (s.isEmpty) return s;
    return s[0].toUpperCase() + s.substring(1).toLowerCase();
  }

  List<({String label, String value})> get fields => [
        (label: 'Full Name',       value: fullName),
        (label: 'Sex',             value: sex),
        (label: 'Age',             value: age),
        (label: 'Document Type',   value: documentType),
        (label: 'Document Number', value: documentNumber),
        (label: 'Issuing State',   value: issuingState),
        (label: 'Nationality',     value: nationality),
        (label: 'Date of Birth',   value: dateOfBirth),
        (label: 'Date of Expiry',  value: dateOfExpiry),
      ];
}

The fields getter returns Dart 3 record types — a concise, type-safe approach that avoids defining a separate FieldEntry class for such simple data.

Step 3: Create Reusable MRZ Result Widgets

Build a small widget library that the screens can compose:

// lib/widgets/mrz_result_card.dart
import 'package:flutter/material.dart';
import '../models/mrz_result_model.dart';

class MrzResultCard extends StatelessWidget {
  const MrzResultCard({super.key, required this.result});
  final MrzResultModel result;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Card(
      elevation: 0,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        side: BorderSide(color: theme.colorScheme.outlineVariant),
      ),
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(children: [
              Icon(Icons.badge_outlined, color: theme.colorScheme.primary),
              const SizedBox(width: 8),
              Text('Travel Document',
                  style: theme.textTheme.titleMedium?.copyWith(
                    fontWeight: FontWeight.bold,
                    color: theme.colorScheme.primary,
                  )),
            ]),
            const Divider(height: 24),
            ...result.fields.map((f) => _FieldTile(label: f.label, value: f.value)),
          ],
        ),
      ),
    );
  }
}

class _FieldTile extends StatelessWidget {
  const _FieldTile({required this.label, required this.value});
  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 140,
            child: Text(label,
                style: theme.textTheme.bodyMedium?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                  fontWeight: FontWeight.w500,
                )),
          ),
          Expanded(
            child: Text(value.isNotEmpty ? value : '—',
                style: theme.textTheme.bodyMedium?.copyWith(
                  fontWeight: FontWeight.w600,
                )),
          ),
        ],
      ),
    );
  }
}

Step 4: Implement the Home Screen and Scan Flow

The home screen handles all scan logic. Key production concerns are addressed here:

  1. Loading guard_isScanning prevents a second scanner from launching while one is already open.
  2. Switch on enum — Dart exhaustive switch ensures all EnumResultStatus cases are handled at compile time.
  3. Mounted guardif (!mounted) return prevents calling setState or Navigator on a disposed widget.
  4. SnackBar feedback — Errors and unexpected states surface to the user without crashing.
// lib/screens/home_screen.dart (key excerpt)
Future<void> _startScan() async {
  if (_isScanning) return;
  setState(() => _isScanning = true);

  try {
    final config = MRZScannerConfig(license: AppConfig.licenseKey);
    final result = await MRZScanner.launch(config);

    if (!mounted) return;

    switch (result.status) {
      case EnumResultStatus.finished:
        final model = MrzResultModel(data: result.mrzData!);
        await Navigator.of(context).push(
          MaterialPageRoute(builder: (_) => ResultScreen(result: model)),
        );
      case EnumResultStatus.canceled:
        break; // user dismissed — no action needed
      case EnumResultStatus.exception:
        _showError('Scan failed: ${result.errorMessage} (${result.errorCode})');
    }
  } catch (e) {
    if (mounted) _showError('Unexpected error: $e');
  } finally {
    if (mounted) setState(() => _isScanning = false);
  }
}

The UI uses FilledButton (Material 3) and shows an inline CircularProgressIndicator while scanning is in progress — a much better UX than disabling the button silently.

Step 5: Display Parsed MRZ Results on a Dedicated Screen

// lib/screens/result_screen.dart
import 'package:flutter/material.dart';
import '../models/mrz_result_model.dart';
import '../widgets/mrz_result_card.dart';

class ResultScreen extends StatelessWidget {
  const ResultScreen({super.key, required this.result});
  final MrzResultModel result;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Scan Result'), centerTitle: true),
      body: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              MrzResultCard(result: result),
              const SizedBox(height: 24),
              FilledButton.icon(
                onPressed: () => Navigator.of(context).pop(),
                icon: const Icon(Icons.document_scanner_outlined),
                label: const Text('Scan Another Document'),
                style: FilledButton.styleFrom(
                  padding: const EdgeInsets.symmetric(vertical: 14),
                  shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(12)),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

MRZ result

Step 6: Wire the App Entry Point with Material 3

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'screens/home_screen.dart';
import 'config/app_config.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
    DeviceOrientation.portraitDown,
  ]);
  runApp(const MrzScannerApp());
}

class MrzScannerApp extends StatelessWidget {
  const MrzScannerApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: AppConfig.appName,
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF1565C0),
          brightness: Brightness.light,
        ),
      ),
      darkTheme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF1565C0),
          brightness: Brightness.dark,
        ),
      ),
      themeMode: ThemeMode.system,
      home: const HomeScreen(),
    );
  }
}

Notable production choices:

  • debugShowCheckedModeBanner: false — removes the debug banner from release screenshots.
  • themeMode: ThemeMode.system — automatically follows the device dark/light preference.
  • Portrait lock via SystemChrome.setPreferredOrientations — consistent scanning frame.

Step 7: Configure Android Permissions and Build Settings

android/app/build.gradle.kts

android {
    namespace = "com.dynamsoft.flutter.mrz_scanner"
    compileSdk = flutter.compileSdkVersion
    ndkVersion = flutter.ndkVersion

    defaultConfig {
        applicationId = "com.dynamsoft.flutter.mrz_scanner"
        minSdk = 21          // Android 5.0 — SDK minimum requirement
        targetSdk = flutter.targetSdkVersion
        versionCode = flutter.versionCode
        versionName = flutter.versionName
    }

    buildTypes {
        release {
            // TODO: Add your own signing config for the release build.
            signingConfig = signingConfigs.getByName("debug")
        }
    }
}

android/app/src/main/AndroidManifest.xml

Add the camera permission and the Android 13+ predictive back gesture opt-in:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-feature android:name="android.hardware.camera" android:required="true" />

    <application
        android:label="MRZ Scanner"
        android:enableOnBackInvokedCallback="true"
        ...>

Step 8: Configure iOS Camera Permissions

ios/Runner/Info.plist

iOS requires an explicit NSCameraUsageDescription. An empty string will be rejected by App Store review:

<key>NSCameraUsageDescription</key>
<string>Camera access is required to scan the Machine Readable Zone (MRZ) on passports and ID cards.</string>

Also update the display name:

<key>CFBundleDisplayName</key>
<string>MRZ Scanner</string>

Install CocoaPods Dependencies

cd ios/
pod install --repo-update

Run and Test Your Flutter MRZ Scanner

Android

flutter devices          # list connected devices
flutter run -d <ID>      # run debug build
flutter run --release    # run optimised release build

iOS

Open ios/Runner.xcworkspace in Xcode, select your device, configure your Apple Developer team under Signing & Capabilities, then press Run.

Common Issues & Edge Cases

  • Scanner fails on emulator: MRZScanner.launch() requires a physical camera. Running on an Android emulator returns EnumResultStatus.exception with a camera-unavailable error — always test on a real device.
  • NSCameraUsageDescription App Store rejection: Apple rejects submissions with an empty or missing camera usage string. Use a specific description that explains the MRZ scanning purpose, as shown in Step 8.
  • Expired or invalid license key: The SDK surfaces EnumResultStatus.exception with result.errorCode containing a license error. Verify your key in the Dynamsoft customer portal and ensure AppConfig.licenseKey is set correctly before building for release.

Source Code

https://github.com/yushulx/flutter-barcode-mrz-document-scanner/tree/main/examples/dynamsoft_mrz