Skip to content
yashraj.
Flutter

Flutter Security for FinTech: KYC, Biometrics, and PII 

Building KYC in Flutter: why the sensitive path leaves Dart, secure document capture, PII that never touches the device, and compliance as configuration.

y
Yashraj Jain
Software Engineer · Bengaluru
Flutter Security for FinTech: KYC, Biometrics, and PII

At iBind Systems I worked on identity verification apps running KYC across 6+ countries, at 97% first-attempt verification. The architecture had two properties that turn out to be the whole story: a config-driven routing engine on a unified JSON schema that cut new-country onboarding effort by roughly 70%, and 3+ native Android modules in Kotlin — MVVM, Coroutines, Jetpack — bridged to Flutter over MethodChannels and EventChannels to integrate third-party KYC SDKs for secure document capture and biometric verification.

Those two facts are the answer to the two questions people actually have about Flutter in FinTech: can you do the sensitive stuff in Dart (mostly no, and that is fine), and how do you handle a dozen different countries' requirements without a dozen different apps (configuration, not code).

One caveat up front, and I mean it: I am not going to name regulations or claim certifications. Compliance requirements are jurisdiction-specific, they change, and the version I half-remember is worse than useless to you. What I can describe is the engineering — the shapes that held up across a lot of jurisdictions with genuinely different rules. Your compliance obligations come from your lawyers. This is about the code they will ask you about.

Why the sensitive path leaves Dart

This surprises people who expect a Flutter engineer to defend Dart. But the sensitive path — document capture, liveness, biometrics — usually has to be native, and the reasons are structural rather than a Flutter deficiency.

The vendor SDKs are native. Identity verification vendors ship Android AARs and iOS frameworks. Their capture flows are tuned, their liveness models are native, and their Flutter bindings — where they exist at all — lag their native SDKs. On a path where a vendor bug means a real user cannot open an account, running a version behind is not a trade you want.

Secure hardware is a platform API. The Android Keystore and iOS Keychain, hardware-backed keys, StrongBox, the Secure Enclave, biometric prompts bound to a key — these are OS surfaces. Dart reaches them through a channel by definition. There is no Dart-native path to a hardware-backed key, and a plugin is just someone else's channel.

Camera frames should not be tourists. If you capture a passport in Dart, decode it in Dart, and hand bytes to a native SDK, that image now exists in the Dart heap, possibly in an image cache, possibly in a temp file. Every copy is a place it can leak and a place you must reason about. Native capture that hands the SDK its own frames means the document image never enters your Dart heap at all — and the best way to secure data is for it to not be there.

So the honest architecture is: Flutter owns the journey; native owns the moment. Flutter renders onboarding, instructions, progress, results, and the country logic. When it is time to photograph a document or check a face, native takes over, the vendor SDK runs in its own Activity, and what comes back across the channel is an outcome — verified, a reference token, a failure code — not an image.

That boundary is a security boundary. The narrower it is, the less there is to audit. Every KYC SDK we integrated ran its capture in its own Activity and the channel was purely a request/result pipe, which removed an entire category of problem. The mechanics of that boundary — replying exactly once, threading, stable error codes — are in my platform channels post; here the point is which side of it the data lives on.

Person holding a phone with a security lock interface displayed
Flutter owns the journey; native owns the moment. A document image should never enter the Dart heap in the first place.

Secure document capture

Document capture looks like a camera problem and is really a data lifecycle problem. The question is not how to take the photo. It is how many copies of a passport now exist, where, and for how long.

The default answer in a naive implementation is grim: a temp file the camera plugin wrote, a Dart Uint8List, an image cache entry, maybe a resized copy for a preview thumbnail, possibly a crash report attachment, and — if you were unlucky enough to use a system camera intent — a copy in the device gallery, backed up to the user's cloud. That is six copies of a government ID, on a device you do not control, from one screen.

The properties to design for:

  • The image goes from sensor to SDK to server without a durable stop. If a vendor SDK captures and uploads directly, take that. Fewer hops, fewer copies, and the bytes were never yours.
  • Never a system camera intent for a document. It writes to shared storage. That copy outlives your app, syncs to the cloud, and appears in the user's photo roll next to their lunch. In-app capture, always.
  • Nothing sensitive in cache directories. Android backup rules and iOS backups will happily include them.
  • Server-side extraction over client-side. If you OCR the document on the device you now have parsed PII in memory and probably in logs. Send the image, let the backend extract, keep the field values out of the client entirely.
  • Screenshots off on capture and review screens. FLAG_SECURE on Android. Cheap, and it also keeps the screen out of the OS task-switcher snapshot — which is a real disk artifact people forget exists.
  • If you must buffer, buffer for seconds. Explicit deletion in a finally, not on a happy path. Encrypted at rest with a key from the platform keystore, not from your source code.

The mental test I would apply to any capture flow: if the device were seized ten minutes after the user finished, what is on it? The correct answer is a status and a reference, and nothing else. If the answer is "well, the temp file should be gone by then," the flow is not designed.

Biometric verification means two different things

Conflating these is a genuine security bug and it is common.

Local device biometrics — Face ID, a fingerprint — authenticate the user to the device. This is local_auth, it is for unlocking your app or authorising a sensitive action, and its guarantee is "the person holding the phone matches an enrolled biometric on this phone."

Biometric identity verification — the selfie-and-liveness step in KYC — authenticates the user to you, by matching a face against the document they just presented and proving that face is a live human. This is a vendor SDK and a server decision.

They are not substitutes and the failure of confusing them is specific: a Face ID success tells you this phone's enrolled owner is present. It does not tell you who that is. Anyone can enrol their own face on a phone. If you accept "biometric passed" as identity, you have verified that someone unlocked a phone.

Two rules follow, and they are the ones I would enforce in review:

A local biometric check must never be a client-side boolean that gates a server action. if (didAuthenticate) { transfer(); } is a check a rooted device removes with a hook. If it authorises something that matters, bind it to a key: use the biometric to unlock a hardware-backed Keystore key, sign a server challenge with it, and let the server verify the signature. Then the guarantee is cryptographic instead of a variable you hope nobody flipped.

The liveness decision belongs to the server. The client requests, the SDK captures, and the verdict comes from the backend. A client that decides its own verification passed is a client that can be told to always pass.

PII and what never touches local storage

The cleanest rule I know, and the one that survived every jurisdiction we shipped to: a phone is a place data passes through, not a place it lives.

DataOn device?WhereWhy
Document imagesNoNowhere durable — sensor to SDK to serverEvery copy is an audit liability and a breach surface
Extracted PII (name, DOB, ID number)NoServer only; extraction happens server-sideIf the client never parses it, the client cannot leak it
Biometric templatesNeverSecure hardware (OS) or the vendor's pipelineNot yours to hold; irrevocable if leaked — you cannot reissue a face
Session / refresh tokensYesKeystore / Keychain via secure storageNeeded for the session; revocable, short-lived
Verification status + reference IDYesOrdinary local storageOpaque by design — meaningless without the server
Country config / routing rulesYesOrdinary local storage, cachedNot secret; it is a schema, not user data
Analytics and crash contextYes, filteredInternal IDs and state flags onlyThe crash pipeline is a data pipeline — it gets the same review

The row people get wrong is the last one. Crash reporting and analytics are exfiltration paths with a friendly name. An exception message containing a document number, a breadcrumb with an email, a custom key holding a phone number — all of it leaves the device to a third party, and none of it went through your data review because it was "just telemetry." Log internal identifiers and state flags, never values. That is the same discipline that drove the crash instrumentation work I did at iMumz, on a health-adjacent app where the constraint was identical.

The other row worth dwelling on is the reference ID. Storing "verification 8f3a-… : approved" locally is safe precisely because it is meaningless off the server. That is the shape to aim for everywhere: the device holds pointers, the server holds meaning. Then a lost phone is a lost pointer.

And on secure storage itself — flutter_secure_storage wrapping Keystore and Keychain is the right tool for tokens, and it is not a vault for documents. It is an OS-backed keystore with real guarantees on a healthy device and weaker ones on a rooted or jailbroken one. Store what must be revocable and short-lived. Do not talk yourself into "it is encrypted, so the passport scan is fine there."

Compliance as configuration, not code

This is the part I would most want another engineer to take away, because it is the difference between a platform and a pile.

Every country wants something different. Different accepted documents, different combinations, different ordering, different vendors, different rules about what happens when verification fails. The obvious implementation is a branch per country. Then a second one. By the sixth you have a routing function nobody can read, six code paths that need testing, and a new-country launch that is an engineering project with a release attached.

The alternative is to notice that these are not different behaviours. They are the same behaviour with different parameters. Every flow is: collect these documents, in this order, through these vendors, with these rules. That is data. So we built a config-driven routing engine on a unified JSON schema — one engine, one code path, one set of tests, and a country is a config document.

{
  "country": "XX",
  "flow": [
    { "step": "document",  "accepts": ["passport", "national_id"], "required": 1 },
    { "step": "liveness",  "vendor": "vendor_a", "retries": 2 },
    { "step": "address",   "accepts": ["utility_bill"], "optional": true }
  ],
  "onFailure": { "manualReview": true, "maxAttempts": 3 }
}

What that bought, in order of how much it mattered:

  • ~70% less effort to onboard a new country. Authoring a config instead of writing, reviewing, and regression-testing a new branch.
  • One code path to test. This is the underrated one. Six branches means six paths, each undertested. One engine means the engine gets tested properly and each country is a fixture — which is a large part of how the 85% coverage was reachable at all.
  • Requirement changes stop being releases. A country adds a document type; that is a config change, not a build, a review queue, and a staged rollout while users fail verification.
  • The rules become readable by people who are not engineers. Compliance and product can read a JSON flow. They cannot read a Dart function with six branches, which means they cannot check your work — and on this kind of platform, them checking your work is the point.

The trade-off, honestly: you have moved complexity from code into a schema, and a bad schema is worse than branches because it fails at runtime instead of compile time. Validate configs in CI, version the schema, and treat a config change with the same seriousness as code. It is code. It just has better ergonomics.

I would also claim this is why 97% first-attempt verification was achievable. First-attempt success is mostly about asking each user for exactly the right thing in the right order — which is a per-country question. When that logic is a config, tuning it is cheap, and cheap tuning is what gets you from acceptable to 97%.

What to hand a vendor, and what to build

The instinct to build is strongest exactly where it is most wrong.

Hand to a vendor: document authenticity detection, liveness and anti-spoofing, face matching, per-country document parsing. These are adversarial ML problems with a full-time attacker on the other side. A vendor with a research team retrains against new spoof techniques continuously. You will not, and a liveness check that was good two years ago is a liveness check attackers have already beaten.

Build yourself: the orchestration — which vendor, which step, which country, what happens on failure. This is your product. It is also your leverage: owning routing is what lets you swap a vendor per country, run two in parallel, or fall back when one has an outage. A platform that hard-codes one vendor's SDK into its flow has outsourced its own architecture.

Never build: your own cryptography, your own biometric matching, your own secure storage over a file with a hard-coded key. This should be uncontroversial and it keeps happening.

The test: if being wrong means an attacker wins, buy it. If being wrong means your product is worse, build it. Anti-spoofing is the first. Country routing is the second.

The Takeaway

Flutter is fine for FinTech, but the sensitive path leaves Dart — vendor KYC SDKs are native, secure hardware is a platform API, and a document image should never enter the Dart heap in the first place. Flutter owns the journey; native owns the moment, and what crosses the channel is an outcome, not an image. Keep local device biometrics and biometric identity verification separate: a Face ID success proves someone unlocked a phone, so bind it to a hardware-backed key and let the server decide anything that matters. The device holds pointers, the server holds meaning — no document images, no extracted PII, no biometric templates, and no PII in your crash pipeline, which is an exfiltration path with a friendly name. And treat per-country requirements as configuration on a unified schema rather than branches: it cut new-country onboarding effort ~70%, left one code path to test, and let people who are not engineers check the rules.

I build mobile software with these constraints and I'm currently open to full-time roles — the platform case study, my résumé, or get in touch.

Frequently asked questions

Is Flutter secure enough for FinTech and KYC apps?

Yes, provided the sensitive path leaves Dart. Vendor KYC SDKs are native, secure hardware is a platform API, and document capture belongs in a native module — Flutter owns the journey, native owns the moment.

Can I use Face ID or fingerprint as identity verification?

No — they answer different questions. A local biometric proves someone unlocked this phone; it does not prove who they are. Identity verification is a document match plus liveness, decided by the server.

What PII should a Flutter app store locally?

Effectively none. Tokens in Keystore or Keychain, plus an opaque verification status and reference ID. No document images, no extracted PII, no biometric templates — the device holds pointers, the server holds meaning.

How do you handle KYC requirements across many countries?

As configuration on a unified JSON schema rather than a branch per country. One engine, one code path, one set of tests — which cut new-country onboarding effort by roughly 70% and let non-engineers read the rules.

What should you build yourself versus hand to a KYC vendor?

Buy anything adversarial — liveness, anti-spoofing, document authenticity, face matching — because an attacker is iterating against it full-time. Build the orchestration: which vendor, which step, which country, what happens on failure. That is your product and your leverage.

Bottom line

In FinTech Flutter, the best way to secure data is for it to never be there — a document image should never enter the Dart heap, and if the device were seized ten minutes later, the only correct answer is a status and a reference.

FlutterSecurityFinTechMobile DevelopmentArchitecture
Share
[ Open to roles ]

Like the way I think?

I'm a mobile engineer — Flutter and React Native for cross-platform, native Android in Kotlin where the platform demands it, Next.js on the web — open to full-time roles. If your team is hiring, the résumé is the fastest way in.