Real-world experience building identity verification for banking apps in Pakistan’s fintech ecosystem.
What KYC Actually Involves (The Full Picture)
Most developers think of KYC as:
Capture front of CNIC (National ID)
Capture back of CNIC
Take a selfie
Submit
The reality in a regulated financial app is closer to:
Document capture with quality validation (blur, glare, framing)
OCR extraction to prefill form fields
Liveness detection to prevent spoofing with photos
Face match — comparing selfie to ID card photo
Address verification — utility bills, rental agreements
Biometric consent collection and audit trail
Retry logic with graceful degradation when third-party APIs fail
Offline queuing for unstable networks
Compliance logging — every step timestamped for regulatory audit
Underestimate any of these and you’ll be pushing hotfixes at 2am.
The Architecture That Works
After rebuilding this flow three times across different apps, I landed on a state-machine approach using flutter_bloc. Each KYC step is a discrete state, and transitions are explicit and auditable.
// kyc_state.dart
abstract class KycState extends Equatable {
const KycState();
}
class KycIdle extends KycState {
@override
List<Object?> get props => [];
}
class DocumentCaptureInProgress extends KycState {
final DocumentSide side;
final DocumentQualityScore? lastScore;
const DocumentCaptureInProgress({
required this.side,
this.lastScore,
});
@override
List<Object?> get props => [side, lastScore];
}
class DocumentCaptureComplete extends KycState {
final CapturedDocument front;
final CapturedDocument back;
const DocumentCaptureComplete({
required this.front,
required this.back,
});
@override
List<Object?> get props => [front, back];
}
class LivenessCheckInProgress extends KycState { ... }
class FaceMatchPending extends KycState { ... }
class KycSubmitting extends KycState { ... }
class KycApproved extends KycState { ... }
class KycFailed extends KycState {
final KycFailureReason reason;
final bool isRetryable;
...
}
The isRetryable flag on KycFailed is something I wish I'd added from day one. Not all failures are equal — a blurry photo is retryable, but a face mismatch after three attempts needs human review, not another retry.
Document Capture: Where Most Apps Cut Corners
The camera widget seems simple. It isn’t.
The Quality Score Problem
Submitting a blurry or glared image to your OCR/verification vendor costs money per API call — and worse, it fails silently (the vendor accepts the image but returns low-confidence data). You want to reject bad images before they leave the device.
class DocumentQualityAnalyzer {
static Future<DocumentQualityScore> analyze(Uint8List imageBytes) async {
final img = await decodeImageFromList(imageBytes);
final blurScore = await _computeLaplacianVariance(imageBytes);
final brightnessScore = await _computeBrightness(imageBytes);
final aspectRatioScore = _checkAspectRatio(
img.width.toDouble(),
img.height.toDouble(),
);
return DocumentQualityScore(
blur: blurScore, // Laplacian variance — higher = sharper
brightness: brightnessScore, // 0.0 to 1.0
aspectRatio: aspectRatioScore,
isAcceptable: blurScore > 80 &&
brightnessScore > 0.3 &&
brightnessScore < 0.85 &&
aspectRatioScore,
);
}
}
Liveness Detection: Don’t Roll Your Own
I made the mistake once of building a basic “blink detection” liveness check using the front camera and ML Kit. It worked fine in testing and fooled approximately no one in production — a printed photo with a hole cut out for the eyes passed 40% of the time.
Write on Medium
Use a proper liveness SDK. Options I’ve integrated in Flutter:
iProov — most robust, used by UK banks. Flutter plugin available.
FaceTec — widely used in APAC fintech. Good SDK but heavy.
AWS Rekognition Video — simpler to integrate if you’re already on AWS.
Local options — for clients with strict data residency requirements, look at on-device models via TFLite, but set expectations: they’re weaker against sophisticated spoofing.
The integration pattern is the same regardless of vendor:
class LivenessCheckBloc extends Bloc<LivenessEvent, LivenessState> {
final LivenessService _service;
LivenessCheckBloc(this._service) : super(LivenessIdle()) {
on<StartLiveness>(_onStart);
on<LivenessSessionComplete>(_onComplete);
}
Future<void> _onStart(StartLiveness event, Emitter<LivenessState> emit) async {
emit(LivenessInProgress());
try {
final sessionToken = await _service.createSession();
emit(LivenessSessionReady(token: sessionToken));
} catch (e) {
emit(LivenessFailed(error: e.toString(), isRetryable: true));
}
}
Future<void> _onComplete(
LivenessSessionComplete event,
Emitter<LivenessState> emit,
) async {
emit(LivenessVerifying());
final result = await _service.verifySession(event.sessionId);
if (result.passed) {
emit(LivenessApproved(sessionId: event.sessionId));
} else {
emit(LivenessFailed(
error: 'Liveness check failed',
isRetryable: result.attemptsRemaining > 0,
));
}
}
}
When the upload is queued, show the user a clear message: “We’ve saved your progress. We’ll submit automatically when your connection improves.” Then use a background service (WorkManager on Android, BGTaskScheduler on iOS) to retry.
Never make the user redo the capture step because of a network failure. That is a UX sin I have witnessed firsthand and it destroys completion rates.
Security Considerations
A KYC flow handles some of the most sensitive personal data your app will ever touch. A few non-negotiables:
Never store raw ID images in app cache unencrypted. Use flutter_secure_storage for tokens and encrypted SQLite (via sqflite_sqlcipher) if you need local persistence of any captured data.
Wipe captured images from memory immediately after upload.
Future<void> _submitAndCleanup(KycPayload payload) async {
try {
await _uploadService.uploadDocuments(payload);
} finally {
// Wipe regardless of success or failure
payload.dispose(); // Zero out byte arrays
await _tempFileManager.clearKycTemp();
}
}
Certificate pinning for your KYC API endpoint. Use dio_certificate_pinning or a custom HttpClient. A KYC endpoint that can be MITMed is a regulatory nightmare.
Screenshot prevention during the flow.
// In your KYC screen's initState
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
// For Android: use FLAG_SECURE via platform channel
// For iOS: it's handled at the OS level, but add a blur overlay for app switcher
The Compliance Audit Trail
Every regulator I’ve encountered in Pakistan’s fintech space (SBP, SECP) wants an audit trail. Every step, every retry, every failure — timestamped and associated with the user session.
class KycAuditLogger {
Future<void> log(KycAuditEvent event) async {
await _auditRepository.insert(
KycAuditEntry(
userId: event.userId,
sessionId: event.sessionId,
step: event.step.name,
outcome: event.outcome.name,
metadata: event.metadata,
deviceInfo: await _deviceInfoService.getDeviceSnapshot(),
timestamp: DateTime.now().toUtc(),
),
);
}
}
Log everything on the client side too (not just server-side), because network failures mean some events may never reach the server. When a user calls support claiming “the app ate my KYC,” your client-side logs are gold.
Completion Rates: The Metric Nobody Talks About
KYC flows have notoriously low completion rates in the industry. From my experience across 4 fintech apps in Pakistan:
Average drop-off is 35–50% if you just stack all the steps sequentially
Showing a progress indicator (Step 2 of 4) reduces drop-off by ~15%
Letting users save and resume a partially complete KYC reduced total abandonment by ~22% in one app I worked on
In-app guidance (short animated tutorials before each capture step) reduces retry rate significantly
The technical work means nothing if users don’t complete the flow.
What I’d Do Differently
If I were starting a new KYC integration today:
Use a hosted KYC solution (Shufti Pro, Jumio, Sum&Substance) for the heavy lifting — OCR, liveness, face match — and build only the capture UI yourself. The API cost is almost always cheaper than the engineering cost.
Design the state machine before writing a single widget. The KYC flow has more states than you think.
Add analytics from day one — funnel tracking on every step, not just the final submission.
Test on low-end Android devices. The front camera on a ~$80 Android phone in the Pakistani market produces very different image quality than your development device.
KYC is one of those features that looks boring from the outside and is genuinely interesting once you’re deep in it — it sits at the intersection of computer vision, security, UX psychology, and regulatory compliance. Get it right and you’re shipping trust. Get it wrong and you’re fielding compliance audit findings.
If you’re building something similar or have questions about any of the patterns above, drop a comment — happy to go deeper on any section
Top comments (0)