> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/security-observability/secure-multi-provider-ekyc-flutter.md).

# Secure Multi-Provider eKYC in Flutter

How I separate camera, QR, MRZ, NFC, consent, liveness, and backend verification into explicit boundaries when integrating multiple eKYC providers in Flutter

## Result

In the Flutter app source I verified, eKYC is not just a camera screen that returns `success`. One provider opens its own document-capture and liveness flow. Another sends the user to an external app for consent, receives a callback, and only then opens a face SDK. A different path uses the camera to read a QR code, extracts a CAN, and starts an NFC session to read data from the chip.

I bring these flows into one mental model:

```
Flutter UI / orchestrator
        │
        ├── capability + permission ──► Android/iOS
        │
        ├── provider adapter ─────────► native wrapper ─► provider SDK
        │
        ├── external consent ─────────► provider app/browser
        │                 callback ───► callback inbox
        │
        └── session + evidence ───────► backend verifier
                                            │
                                            └── final KYC decision
```

The app owns the state machine, lifecycle, navigation, retry, and redaction. The native wrapper owns the bridge from Dart to Kotlin or Swift. The provider SDK owns its provider-specific UI and processing. The operating system owns camera permission, the NFC reader session, and app-link delivery. The backend is the authority that verifies the session and evidence before advancing KYC state.

After separating these boundaries, I can answer the important questions explicitly:

* Is the camera scanning a QR code, OCRing a document, or capturing a face?
* Does the MRZ come from camera OCR or a data group on the chip?
* How is a device without NFC different from a device where NFC is disabled?
* How is user cancellation different from permission denial or a provider error?
* Which session owns a callback received after the app entered the background?
* Has the provider SDK finished, or has the backend accepted the KYC result?
* Which data may enter memory, storage, and telemetry?

The result is an architecture whose layers can be tested independently. It is not a claim that the current source is production-ready or provides a particular level of security assurance. Areas without backend, vendor, or hardware evidence remain requirements that must be verified.

## Problem

### `SDK success` Is Not `KYC approved`

The easiest mistake is to let a widget call a provider SDK, receive a Boolean or result map, and navigate directly to a success screen.

A provider callback only proves that the SDK completed one stage according to its contract. By itself, that callback does not prove that:

* The session belongs to the correct user and flow.
* The evidence is still valid and has not been replayed.
* Document or chip authenticity and integrity were verified.
* Liveness and face matching satisfied the required policy.
* The backend persisted the final decision.

Authority also differs between the two case studies I inspected. The VNPay flow returns document and liveness results through its wrapper. The VNeID flow sends the user through external consent, then submits face evidence to the backend for verification. A UI that only sees `success` hides this important distinction.

I use this rule:

> Provider success is the outcome of one stage. The backend decision is the outcome of the flow.

### Camera, QR, and MRZ Are Three Different Concepts

One source screen is named after MRZ, but its scanner actually accepts only QR codes. The QR data is parsed to obtain a CAN; the CAN opens a PACE session with the chip; and the MRZ is read from a data group after NFC connects successfully.

```
Camera frame
   │
   ▼
QR decoder ─► QR payload ─► CAN ─► PACE/NFC session
                                      │
                                      ├─ DG1 ─► MRZ and identity fields
                                      ├─ DG2 ─► portrait image
                                      └─ SOD ─► verifier input
```

Calling every step “MRZ scanning” quickly makes the code and tests semantically wrong:

* Camera permission becomes mixed with NFC capability.
* The QR parser is mistaken for a document parser.
* Reading DG/SOD is mistaken for verifying a document.
* QR errors, tag loss, and hash or signature failures collapse into one message.

In this article, I call the first camera stage `qrBootstrap`. Only a component that actually OCRs the machine-readable zone should use a name such as `scanMrz`.

### Capability, Permission, and Attempt Outcome Are Not One State

Before opening the camera or NFC, the app must distinguish these groups:

| Group      | Example                                                                                                                     |
| ---------- | --------------------------------------------------------------------------------------------------------------------------- |
| Capability | Does the device have camera or NFC hardware?                                                                                |
| OS state   | Is NFC disabled, or is the reader session busy?                                                                             |
| Permission | Has camera access never been requested, denied, or permanently denied?                                                      |
| Attempt    | Did the user cancel, did the operation time out, was the tag lost, or did the provider return an error or malformed result? |

A preflight timeout does not mean the hardware is ready. User cancellation does not mean the device is unsupported. Permission denial should not appear as “provider failure.”

The source I inspected has an availability-first fallback: a timeout or exception while checking NFC may let the flow continue so the SDK can handle the condition later. This favors completion rate, but the domain model should still preserve `unknown` instead of rewriting it as `ready`. Product policy may choose to continue, while telemetry and UI still know that preflight did not confirm readiness.

### External Callbacks Are Easy to Lose in an In-Memory Stream

The consent flow backgrounds the app, then waits for a deep link to return. In a warm flow, a broadcast stream with an active listener can work. The callback can also arrive when:

* The OS recreated the Activity or process.
* The app cold-started from the link.
* The original page was disposed.
* The provider delivered the callback more than once.
* A callback from an old session arrived after the user started a new one.

The current source correlates the callback with the request reference it is holding. This is a necessary check, but a broadcast stream does not replay or buffer events, and page state is not a durable recovery store.

I do not treat a request identifier in the URL as a signature. A callback remains untrusted input until the backend redeems it and checks ownership, expiry, and one-time use.

### eKYC Data Does Not Belong in Convenience Logging

An eKYC flow can carry a document number, MRZ, data groups, SOD, document images, face images, nonces, tokens, and signatures. These values are easily exposed when code:

* Logs all `MethodCall.arguments` or a result map.
* Interpolates configuration or results into `debugPrint` or `toString()`.
* Logs the complete deep-link URL and query.
* Sends a raw exception to analytics or crash breadcrumbs.
* Persists screen state for UI recovery.

I do not include real logs, keys, callback URLs, or payloads from the source in this article. The public pattern treats all identity and biometric evidence as **not loggable**. Telemetry receives only the stage, an allowlisted outcome, and a duration bucket.

### Threat Model and Limits

The architecture in this article reduces races between stages, keeps callbacks with the correct session, gives permission and capability states explicit meanings, and prevents sensitive payloads from entering app-owned loggers.

It assumes that transport, certificate validation, provider binaries, and backend identity and session controls are configured correctly. It does not protect a hooked app process, a rooted or jailbroken device, a modified SDK, or a user who is tricked into presenting a document to an impersonating app.

Reading a chip does not prove that the chip or its data is authentic unless the SOD, data-group hashes, and trust chain are verified. PACE establishes secure messaging for supported profiles but does not replace Passive Authentication. Liveness also does not prove identity by itself without appropriate face matching and a backend or provider decision.

## Solution

### Separate Ownership Before Writing an Adapter

I start with an ownership table rather than a provider API:

| Layer            | Owns                                                               | Must not decide by itself                            |
| ---------------- | ------------------------------------------------------------------ | ---------------------------------------------------- |
| Flutter UI       | Copy, progress, cancel, retry, and navigation                      | Whether the document or user has been verified       |
| Orchestrator     | State machine, single-flight, lifecycle, and correlation           | Whether provider evidence is cryptographically valid |
| Provider adapter | Mapping configuration, results, and errors into a neutral contract | Final backend state                                  |
| Native wrapper   | Activity/ViewController and channel callbacks                      | The app's business flow                              |
| OS               | Camera permission, NFC session, and app-link delivery              | Identity authenticity                                |
| Backend verifier | Session, evidence, anti-replay, idempotency, and audit             | UI lifecycle on the device                           |

An adapter hides implementation differences, but it does not hide the source of authority. I still want to know whether an outcome came from `device`, `providerSdk`, or `backend`.

### Model the Flow as a State Machine

```dart
enum EkycStage {
  idle,
  preflight,
  startingSession,
  qrBootstrap,
  readingChip,
  awaitingExternalConsent,
  capturingDocument,
  liveness,
  verifying,
  completed,
  cancelled,
  retryable,
  rejected,
}

final class EkycSessionRef {
  const EkycSessionRef(this.value);

  final String value;

  @override
  String toString() => 'EkycSessionRef(<redacted>)';
}
```

My state graph has two main branches:

```
idle
  │ user starts
  ▼
preflight ── unavailable/denied ──► recoverable stop
  │ ready or policy allows an attempt
  ▼
startingSession
  │ sessionRef
  ├──────── document path ────────┐
  │                               ▼
  │                        capture / qrBootstrap
  │                               │
  │                               ▼
  │                           readingChip
  │                               │ evidence
  │                               └──────────────┐
  │                                              │
  └──────── external consent path ─► awaitingExternalConsent
                                                 │ callback redeemed
                                                 ▼
                                             liveness
                                                 │ evidence
                                                 ▼
                                             verifying
                                     ┌───────────┼───────────┐
                                     ▼           ▼           ▼
                                  completed   retryable    rejected
```

Each active stage also has three common transitions:

* User cancel: close the camera, NFC session, or provider SDK, then move to `cancelled`.
* App background: suspend resources according to their contract and save the minimum checkpoint only when recovery requires it.
* Process death: query the backend again with an opaque session reference; do not reconstruct success from an old Boolean in the UI.

### Block Overlapping Operations at the Service Boundary

At the call site I verified, the start branch calls backend initialization once, but it does not `await` the Future and has no explicit single-flight guard. I do not call this a duplicate runtime bug. I treat repeated taps, re-entry, and late completion as risks that require tests.

The guard belongs at the orchestrator or service boundary, not only in a disabled button or loading dialog:

```dart
enum StartOutcome { completed, cancelled, unavailable, failed, busy }

final class EkycOrchestrator {
  EkycOrchestrator(this._backend, this._provider);

  final EkycBackend _backend;
  final EkycProviderAdapter _provider;

  bool _running = false;
  int _generation = 0;

  Future<StartOutcome> start() async {
    if (_running) return StartOutcome.busy;

    _running = true;
    final generation = ++_generation;

    try {
      final session = await _backend.begin();

      if (generation != _generation) {
        return StartOutcome.cancelled;
      }

      final evidence = await _provider.captureDocument(session);

      if (generation != _generation) {
        return StartOutcome.cancelled;
      }

      return await _backend.verify(session, evidence);
    } finally {
      await _provider.close();
      _running = false;
    }
  }

  void cancel() {
    _generation++;
  }
}
```

This code illustrates ownership only. Production code must also dispose of evidence, map typed errors, and define how `cancel()` closes a native operation. Backend `begin()` and `verify()` still need to be idempotent because network retries and duplicate callbacks do not disappear when a local guard is added.

### Normalize Results into Typed Outcomes

I do not let a widget read provider codes, maps, or raw exceptions:

```dart
sealed class EkycOutcome<T> {
  const EkycOutcome();
}

final class EkycSucceeded<T> extends EkycOutcome<T> {
  const EkycSucceeded(this.value);
  final T value;
}

final class EkycCancelled<T> extends EkycOutcome<T> {
  const EkycCancelled();
}

final class EkycPermissionDenied<T> extends EkycOutcome<T> {
  const EkycPermissionDenied({required this.canOpenSettings});
  final bool canOpenSettings;
}

final class EkycUnavailable<T> extends EkycOutcome<T> {
  const EkycUnavailable(this.capability);
  final String capability;
}

final class EkycRetryableFailure<T> extends EkycOutcome<T> {
  const EkycRetryableFailure(this.reason);
  final String reason;
}

final class EkycRejected<T> extends EkycOutcome<T> {
  const EkycRejected();
}
```

`reason` is only an allowlisted enum or string such as `cameraBusy`, `tagLost`, or `providerTemporarilyUnavailable`. It is not a raw provider message.

The provider adapter exposes a neutral contract:

```dart
abstract interface class EkycProviderAdapter {
  Future<EkycOutcome<DocumentEvidence>> captureDocument(
    EkycSessionRef session,
  );

  Future<EkycOutcome<LivenessEvidence>> captureLiveness(
    EkycSessionRef session,
  );

  Future<void> close();
}
```

VNPay and VNeID can use different adapters, but the UI does not need to know each SDK's native method names, result codes, or payload shapes.

### Separate the Capability Gate from the Provider Adapter

```dart
enum CapabilityState {
  ready,
  disabled,
  unavailable,
  permissionDenied,
  permissionPermanentlyDenied,
  unknown,
}

abstract interface class DeviceCapabilityGate {
  Future<CapabilityState> camera();
  Future<CapabilityState> nfc();
}
```

I apply these policies:

* Request camera permission when the user starts capture, not unnecessarily at app startup.
* Give `permissionPermanentlyDenied` a route to Settings; do not turn `cancelled` into a preference change.
* Give NFC `unavailable` a fallback method when chip-based eKYC is optional.
* For `disabled`, guide the user to enable NFC and then check again.
* Return `unknown` on timeout; product policy decides whether to fail closed or try the provider.
* Return a typed `unavailable` outcome on a platform without a native implementation instead of letting `MissingPluginException` reach the widget.

### Manage the Camera with the App Lifecycle

Camera resources should follow an explicit lifecycle:

```
enter stage
  ├─ check/request permission
  ├─ await app resumed
  ├─ create/start controller
  └─ receive frames for the current session generation

inactive/paused
  └─ stop frame delivery or release according to the plugin contract

resumed
  └─ recreate only if the state still requires the camera

cancel/dispose/error
  └─ stop + dispose exactly once
```

Every continuation after `await` must check that the widget is still `mounted` or that the operation generation is still current. A camera acquisition error should not be swallowed by an empty catch; it must map to an outcome that supports retry, opening Settings, or switching methods.

The QR bootstrap parser also needs its own boundary:

```dart
sealed class QrBootstrapResult {
  const QrBootstrapResult();
}

final class QrBootstrapAccepted extends QrBootstrapResult {
  const QrBootstrapAccepted(this.accessKey);
  final String accessKey;
}

final class QrBootstrapRejected extends QrBootstrapResult {
  const QrBootstrapRejected();
}
```

Do not log the raw QR value or `accessKey`. If the actual use case OCRs an MRZ, the parser must validate document profile, length, character set, dates, and check digits with public test vectors. Correct OCR formatting is still not authenticity verification.

This section only places QR scanning at the correct eKYC boundary. Camera permission, lifecycle, scan windows, gallery input, and single-flight processing are covered separately in:

{% content-ref url="/pages/twFPlbi5JMfwEKL2J8tP" %}
[Safe QR/Barcode Scanning](/flutter/my-flutter/ui-media/qr-barcode-scanner-camera-permission.md)
{% endcontent-ref %}

### Separate NFC Read, Parse, and Verify

I split NFC into three layers:

1. `read`: open the reader session, connect to the tag, handle timeout, cancellation, tag loss, and cleanup.
2. `parse`: decode MRZ and data groups, then validate their structure.
3. `verify`: check the SOD, hashes, trust policy, and chip-auth evidence for the document profile.

```dart
abstract interface class ChipReader {
  Future<EkycOutcome<ChipEvidence>> read({
    required String accessKey,
  });
}

abstract interface class DocumentVerifier {
  Future<EkycOutcome<VerifiedIdentityRef>> verify({
    required EkycSessionRef session,
    required ChipEvidence evidence,
  });
}
```

The source app reads PACE, COM, selected data groups, and the SOD. It also sends raw evidence to the backend. However, the scoped client source does not show that the SOD signature, data-group hashes, and signer trust chain are verified on the client. Backend source was outside the verification scope.

I therefore keep these steps as backend requirements:

```
Backend verifier
  ├─ check session, owner, provider, and expiry
  ├─ check the expected document profile
  ├─ verify the SOD signature under the trust policy
  ├─ verify hashes for accepted data groups
  ├─ verify fresh chip-auth evidence when policy requires it
  └─ return the authoritative decision
```

If Active Authentication is used, each attempt needs a random, unpredictable challenge. The response must be bound to the correct challenge and session and checked by the verifier. Calling a native API and discarding the response does not support a security claim.

ICAO Doc 9303 provides terminology and mechanisms for eMRTDs. I do not use it to claim that every identity document or provider profile implements the complete standard. The vendor and backend owners must confirm the specific document profile.

### Recover External Consent with a Callback Inbox

Instead of making a page listen directly to a broadcast stream, I place callback intake at the app level:

```
Backend creates a pending session
          │
          ▼
App stores {opaque session ref, expected stage, expiry}
          │
          ▼
Open provider app/browser ─► app background
          │
callback / cold start
          ▼
App-level callback inbox
          │ parse an allowlisted route without logging the query
          ▼
Backend redeems(callback artifact, session ref)
          │ verify owner, expiry, and one-time use
          ▼
Resume state or require retry/restart
```

The checkpoint contains only an opaque session reference, expected stage, and expiry. It does not contain a document number, MRZ, image, token, or callback query.

The inbox can consume once on the client to keep the UI from processing the same event twice, but the backend must remain idempotent. When the callback arrives before the page is built, the app-level inbox retains it for the orchestrator. When it belongs to an old or expired session, the app ignores it and queries backend state again.

I also check the result of opening the external app. If launch fails, the flow returns a recoverable outcome instead of waiting indefinitely for a callback.

### Only the Backend Advances KYC to a Final State

The backend operation needs at least these invariants:

```
verifyAndAdvance(sessionRef, evidence):
  lock/load the pending session
  require owner, provider, expected stage, and expiry
  reject an already-consumed callback/evidence
  verify the provider response or document evidence
  write audit metadata without raw identity payloads
  atomically transition to completed, retryable, or rejected
  return an opaque next-state decision
```

The app re-fetches backend state after resume or process death. Navigation to a detail-confirmation screen may be the next KYC step, but it should not be named or presented as authoritative approval unless the backend has returned that decision.

### Keep Evidence Out of Logs and Persistent UI State

I use this data table as an API contract:

| Data                           | Memory                  | Persistent storage                         | Logs/analytics                           |
| ------------------------------ | ----------------------- | ------------------------------------------ | ---------------------------------------- |
| Opaque session reference       | Yes, scoped to the flow | Yes when recovery requires it, with expiry | Hash or redact only when truly necessary |
| Document number/MRZ            | Short scope             | Only under an approved policy              | No                                       |
| Document/face images           | Shortest possible scope | Not by default                             | No                                       |
| Data groups/SOD                | Verification scope      | Not by default                             | No                                       |
| Provider token/nonce/signature | Operation scope         | Not by default                             | No                                       |
| Callback artifact              | Until redemption        | Not by default                             | No                                       |
| Stage/outcome/duration bucket  | Yes                     | According to telemetry policy              | Yes, without PII                         |

eKYC contains camera input, identity documents, and face evidence, so capture privacy must be decided per surface instead of treating screenshot events, recording, app-switcher snapshots, and Android prevention as one mechanism. I separate that policy here:

{% content-ref url="/pages/qjE6UMIKIbqVTsDVJlzD" %}
[Screenshot Detection and Background Privacy in Flutter](/flutter/my-flutter/security-observability/screenshot-background-privacy-flutter.md)
{% endcontent-ref %}

The logger boundary does not accept an `Object error`, URL, configuration object, or result:

```dart
void trackEkycTransition({
  required EkycStage from,
  required EkycStage to,
  required String outcomeClass,
  required int durationBucketMs,
});
```

For a sensitive model, `toString()` reports only presence:

```dart
final class LivenessEvidence {
  const LivenessEvidence({
    required this.hasFace,
    required this.hasSignature,
  });

  final bool hasFace;
  final bool hasSignature;

  @override
  String toString() =>
      'LivenessEvidence(hasFace: $hasFace, hasSignature: $hasSignature)';
}
```

Base64 creates additional copies and cannot be reliably zeroized like an app-controlled buffer. When the provider allows it, I prefer a byte buffer or temporary native handle, upload early, avoid persisting the value in Redux, and clean it up on a best-effort basis after the backend receives it.

### Android

The source app uses Android min SDK 24 and compile/target SDK 36. Its root manifest declares NFC permission. Camera permission and an optional camera feature are merged from the scanner dependency, so inspecting only the source manifest is insufficient; the build gate must inspect the artifact's merged manifest.

When NFC is optional, the app can avoid declaring the hardware as required and check availability at runtime. This keeps the app installable on devices without NFC, but it requires an explicit fallback.

Both native wrappers in the case study retain an Activity to open their SDK. A safer contract must:

* Set the new Activity on both attach and reattach.
* Clear the reference on detach.
* Return `notReady` without a foreground Activity.
* Allow only one active native operation.
* Discard stale callbacks with an operation generation.
* Complete each channel result exactly once.
* Close the SDK on cancel, error, and detach paths.

The VNPay wrapper in the source registers only on Android. The public adapter must therefore gate the platform or return `unavailable` on iOS until a real implementation exists. I did not run the provider SDK on Android hardware, so this conclusion comes from source registration, not runtime certification.

### iOS

The source iOS app has a deployment target of 15.0. The app target includes camera and NFC usage descriptions, ISO7816 configuration, and the NFC reader-session entitlement in the build configurations I inspected.

For camera access, `NSCameraUsageDescription` is required before the app accesses a capture device. Core NFC requires the capability or entitlement, a usage description, and supported hardware. The final archive still needs to be inspected instead of inferring its configuration from source files alone.

The VNeID wrapper has a Swift implementation and vendored frameworks. Its native branch explicitly returns an unsupported error on the simulator, so a Dart channel mock or iOS Simulator does not prove the SDK works. A physical iPhone is a required test gate for camera, liveness, and provider presentation.

The NFC UI on iOS uses the system reader sheet and alert message, while Android can show progress and cancellation in Flutter UI. The adapter unifies outcomes without forcing both lifecycles to have identical UI.

### Verify the Result

I split testing into four layers.

#### 1. Domain and Adapter Tests

* A repeated action while an operation is active returns `busy` or is coalesced.
* A stale callback does not mutate the new session.
* Success, cancellation, permission denial, unavailable, retryable failure, and malformed result map separately.
* `close()` always runs in `finally`.
* An unsupported platform returns a typed outcome.
* Sensitive models and loggers do not print raw values.

#### 2. Camera, QR, MRZ, and NFC Tests

* Camera permission: allow, deny, permanently deny, and return from Settings.
* QR parser: missing delimiter, short input, duplicate frame, and different locales.
* MRZ parser: document profile, check digits, dates, and invalid characters.
* NFC: unavailable, disabled, timeout, tag loss, cancellation, background, and retry.
* DG/SOD mapping: missing group, malformed bytes, oversized input, and evidence allowlist.
* Chip verification: trust failure, hash mismatch, and replay of an old challenge.

#### 3. Callback and Backend Tests

* Warm callback, background callback, and cold-start callback.
* Callback before listener, duplicate, out-of-order, wrong session, and expired callback.
* Idempotent backend initialization, redemption, and verification under concurrent requests.
* Rejection of already-consumed callbacks or evidence.
* Re-fetching authoritative state after process death.

#### 4. Device and Observability Tests

| Case                      | Android            | iOS                                       | Expected result                       |
| ------------------------- | ------------------ | ----------------------------------------- | ------------------------------------- |
| Camera allow/deny         | Physical device    | Physical iPhone                           | Correct outcome and Settings recovery |
| No NFC                    | Suitable device    | Unsupported device if still in the matrix | Fallback without a crash              |
| NFC disabled              | Physical device    | According to iOS capability               | Not conflated with unsupported        |
| Tag loss/timeout          | Chip/test fixture  | Chip/test fixture                         | Reader closes and retry is explicit   |
| App backgrounded mid-flow | Yes                | Yes                                       | No stale callback or resource is used |
| Provider cancel/error     | Vendor sandbox     | Vendor sandbox                            | Typed outcome is mapped correctly     |
| Activity recreation       | Yes                | Not applicable in the same form           | No stale Activity is retained         |
| Sensitive-log canary      | logcat + collector | syslog + collector                        | No PII or evidence appears            |

During verification for this article:

* Two VNPay wrapper configuration tests passed.
* Two VNeID wrapper MethodChannel configuration tests passed.
* Two focused app tests for NFC/i18n and update-ID did not compile or load because generated source and workspace dependencies were incomplete; they did not reach their assertions.
* I did not run camera or NFC on physical devices, a native build or archive, a provider app or SDK sandbox, or backend integration.
* I did not have backend source to confirm Passive Authentication, face matching, callback anti-replay, retention, or atomic decisions.

I therefore describe only Dart serialization and call ordering as tested. I do not use these four unit tests to claim native-flow behavior or security assurance.

### Common Mistakes and Trade-Offs

#### Calling a Preflight Timeout `ready`

Availability increases because the app continues to try the provider, but telemetry loses information. Preserve `unknown` in the domain and let policy choose whether to continue or stop.

#### Using Button Loading as a Single-Flight Guard

A button does not prevent re-entry, stale callbacks, or calls from another boundary. The guard belongs in the orchestrator or service, and the backend still needs idempotency.

#### Calling a QR Scanner an MRZ Scanner

The wrong name leads to the wrong tests and ownership. Name the stage after its actual output: QR bootstrap, MRZ parsing, or document capture.

#### Reporting a Valid Document After Reading the SOD

The SOD is verification input. The verifier must still check the signature, trust, and data-group hashes for the document profile.

#### Using a Request Identifier as a Callback Signature

An identifier provides correlation only. The backend must redeem the artifact and check its owner, expiry, and one-time use.

#### Persisting the Entire Page State for Recovery

Recovery becomes easier, but identity and biometric evidence live longer. Persist only an opaque session checkpoint and retrieve authoritative state from the backend again.

#### Logging Raw Errors for Easier Debugging

A raw provider or backend error can contain identifiers or payloads. Map it to an allowlisted reason and keep sensitive details out of logs and analytics.

#### Assuming Android and iOS Have the Same Native Coverage

A package with a shared Dart API can still register only one platform or fail on a simulator. Test plugin registration, Pod and Gradle integration, and hardware matrices separately.

#### Using Obfuscation Instead of Data Minimization

Minification and app shielding do not protect data that already exists in memory or logs. Collecting less data, shortening its lifetime, and keeping the verifier on the backend remain the primary boundaries.

### Verified Versions and Scope

* Flutter: 3.41.2.
* Dart SDK: 3.11.0 or later and earlier than 4.0.0.
* Android app: min SDK 24, compile/target SDK 36.
* iOS app: deployment target 15.0.
* VNPay case study: the current wrapper source registers only on Android.
* VNeID case study: the wrapper supports Android and iOS; the native SDK was not verified on a simulator.

I do not disclose versions or fingerprints of private provider binaries. When upgrading an SDK, recheck the native dependency matrix, minimum OS, privacy manifest, manifest merge, ProGuard/minification, error codes, result schema, and device tests. Do not replace a binary and assume the adapter contract remains compatible.

### References

* [Flutter — Writing custom platform-specific code](https://docs.flutter.dev/platform-integration/platform-channels)
* [Flutter — Testing plugins](https://docs.flutter.dev/testing/testing-plugins)
* [Android — Declare app permissions](https://developer.android.com/training/permissions/declaring)
* [Android — NFC basics](https://developer.android.com/develop/connectivity/nfc/nfc)
* [Apple — Requesting authorization to capture media](https://developer.apple.com/documentation/AVFoundation/requesting-authorization-to-capture-and-save-media)
* [Apple — Core NFC](https://developer.apple.com/documentation/CoreNFC)
* [Apple — Building an NFC Tag-Reader App](https://developer.apple.com/documentation/CoreNFC/building-an-nfc-tag-reader-app)
* [ICAO — Doc 9303 series](https://www.icao.int/publications/doc-series/doc-9303)
* [ICAO — Doc 9303 Part 11](https://www.icao.int/sites/default/files/publications/DocSeries/9303_p11_cons_en.pdf)
* [ICAO PKD — ICAO Master List](https://www.icao.int/icao-pkd/icao-master-list)
* [OWASP MASWE-0005 — Sensitive Data in Logs](https://mas.owasp.org/MASWE/MASVS-STORAGE/MASWE-0005/)

## Conclusion

After separating eKYC into these boundaries, I no longer let a widget call an SDK and decide by itself that the user has been verified. Camera and QR, MRZ and NFC, external consent, liveness, and backend verification each have their own state, authority, and failure modes.

The app keeps one active session, maps provider results into typed outcomes, recovers callbacks through an app-level inbox, and persists only an opaque checkpoint. Identity and biometric evidence do not enter logs or persistent UI state. The backend verifies evidence and advances state idempotently; SDK success only completes one stage.

This architecture fits an app that integrates multiple providers while keeping a stable, testable domain contract. It is not evidence of production readiness: hardware, vendor SDKs, document profiles, and backend verification still need to be tested before making any security-assurance claim.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
