System Design II: Fintech Claims/Payment App
Designing a secure, compliant payment and claims processing system — auth, encryption, document handling, and error cascades
Open interactive version (quiz + challenge)Real-world analogy
Designing a fintech app is like designing a bank vault inside a shopping mall. The mall needs to be accessible and fast, but the vault needs multiple locks, audit logs of every access, and fail-safe mechanisms — if one lock breaks, the vault stays locked, not open.
What is it?
A fintech claims and payment system design covers the security architecture, authentication patterns, encrypted storage, idempotent API design, document handling, and compliance requirements needed to build a production-grade financial application in Flutter.
Real-world relevance
Payback and TapMeHome are fintech apps requiring claims processing with document upload, BankID authentication, and strict audit trails. The Flutter client uses flutter_secure_storage for JWT storage, certificate pinning via dio, and the saga pattern on the backend to handle multi-step claim payouts with automatic compensation on failure.
Key points
- Fintech requirements clarification — Always clarify before designing: transaction volume (TPS), geography and currency support, compliance requirements (PCI-DSS for card data, GDPR for EU users, SOC 2 for enterprise clients), settlement requirements (real-time vs T+2), reversal and dispute windows, fraud detection requirements, and regulatory reporting needs.
- Security-first architecture principle — Fintech systems are threat modeled from day one. OWASP Mobile Top 10 applies to Flutter: insecure data storage, unprotected endpoints, insufficient transport layer security, client-side injection, reverse engineering. Defense in depth: every layer assumes the one above it can be compromised.
- Authentication architecture — BankID and strong auth — For Scandinavian markets: BankID/MitID provides government-grade identity verification. Flow: initiate auth → redirect to BankID app → receive signed JWT with identity claims. For other markets: SMS OTP + TOTP as MFA. JWT short-lived (15min) + refresh token (7 days, rotated on use, stored in secure storage not localStorage).
- Encrypted local storage — Sensitive data (token, account numbers, cached claim data) must never be in plain SharedPreferences. Use flutter_secure_storage (iOS Keychain, Android EncryptedSharedPreferences / Keystore). For structured sensitive data: drift with SQLCipher encryption. Never log sensitive values — redact in error reports.
- API design for payment flows — Payment endpoints must be idempotent. Use idempotency keys (client sends UUID header, server returns same response for duplicate requests). Payment state machine: INITIATED → PROCESSING → COMPLETED / FAILED / REVERSED. Never allow direct state jumps — validate transitions server-side. Return machine-readable error codes, not just HTTP status.
- Document upload for claims — Claims require document evidence (photos, PDFs). Flow: client requests presigned S3 URL → uploads directly to S3 → sends documentId to API → API validates ownership and associates with claim. Client-side: compress images before upload (flutter_image_compress). Enforce file type allowlist. Scan for malware server-side (ClamAV or AWS Macie).
- Error handling cascades — Fintech errors have business consequences. Design for: network timeout (retry with exponential backoff + idempotency key), partial payment (saga pattern — compensating transactions), third-party gateway failure (circuit breaker), user session expired mid-payment (save payment state, resume after re-auth). Never show raw error messages to users — map to user-friendly strings.
- Saga pattern for distributed transactions — When a claim payout involves multiple steps (verify claim → reserve funds → initiate transfer → update ledger → send notification), use the Saga pattern. Each step has a compensating transaction. If step 3 fails, automatically reverse steps 1 and 2. Choreography sagas (event-driven) suit microservices; orchestration sagas (central coordinator) are easier to debug.
- Audit logging and compliance — Every financial action must be logged: who, what, when, from which IP/device. Logs are immutable (append-only, separate from application DB). PostgreSQL with insert-only policy, or dedicated audit log service. Log retention: 7 years for most financial regulations. Audit logs must be available for regulatory inspection within 24 hours.
- Flutter security implementation — Certificate pinning: prevent MITM attacks by pinning the server's TLS certificate. Use dio with a custom certificate validator or package:ssl_pinning_plugin. Root/jailbreak detection: package:flutter_jailbreak_detection — deny app function on compromised devices for regulatory compliance. Screenshot prevention: FLAG_SECURE on Android, iOS overlayWindow on backgrounding.
- Offline handling in fintech — Fintech apps should be conservative about offline capabilities. Acceptable offline: view cached account balance (marked as 'last updated'), view claim history. NOT acceptable offline: initiate payments, submit new claims. Show clear 'offline' banners. Queue non-critical actions (support messages) but gate financial transactions behind connectivity check.
- Interview narration for fintech design — Signal seniority with: (1) Mentioning compliance by name (PCI-DSS, GDPR) without prompting. (2) Proposing the saga pattern for multi-step transactions before the interviewer asks about failure scenarios. (3) Discussing idempotency before discussing retry logic. (4) Noting that the audit log should be a separate system from the application database. (5) Raising the BankID/MFA requirement proactively for EU fintech.
Code example
// Fintech Flutter: Secure auth + payment flow
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:dio/dio.dart';
// Secure token storage
class SecureTokenStore {
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
);
static Future<void> saveTokens({
required String accessToken,
required String refreshToken,
}) async {
await Future.wait([
_storage.write(key: 'access_token', value: accessToken),
_storage.write(key: 'refresh_token', value: refreshToken),
]);
}
static Future<String?> getAccessToken() =>
_storage.read(key: 'access_token');
static Future<void> clearAll() async {
await _storage.deleteAll();
}
}
// Dio with auth interceptor + certificate pinning
class ApiClient {
static Dio create() {
final dio = Dio(BaseOptions(
baseUrl: 'https://api.payback-app.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
));
// Certificate pinning (simplified — use ssl_pinning_plugin for production)
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.badCertificateCallback = (cert, host, port) {
// Validate against pinned certificate SHA256 fingerprint
return CertificatePinner.isValid(cert, host);
};
return client;
};
// Auth interceptor with token refresh
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await SecureTokenStore.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
options.headers['X-Idempotency-Key'] = const Uuid().v4();
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
// Attempt token refresh
final refreshed = await _refreshToken();
if (refreshed) {
// Retry original request
handler.resolve(await dio.fetch(error.requestOptions));
return;
}
// Refresh failed — clear tokens and redirect to login
await SecureTokenStore.clearAll();
GetIt.I<AuthRouter>().navigateToLogin();
}
handler.next(error);
},
));
return dio;
}
}
// Payment state machine — enforced both client and server
enum PaymentStatus { initiated, processing, completed, failed, reversed }
class PaymentStateMachine {
static const Map<PaymentStatus, Set<PaymentStatus>> _validTransitions = {
PaymentStatus.initiated: {PaymentStatus.processing},
PaymentStatus.processing: {PaymentStatus.completed, PaymentStatus.failed},
PaymentStatus.failed: {PaymentStatus.reversed},
PaymentStatus.completed: {PaymentStatus.reversed},
PaymentStatus.reversed: {},
};
static bool canTransition(PaymentStatus from, PaymentStatus to) =>
_validTransitions[from]?.contains(to) ?? false;
}
// Claim document upload with presigned URL
class ClaimDocumentService {
final Dio _dio;
Future<String> uploadDocument(File imageFile) async {
// 1. Compress image
final compressed = await FlutterImageCompress.compressAndGetFile(
imageFile.path,
'${imageFile.path}_compressed.jpg',
quality: 80,
minWidth: 1920,
);
// 2. Get presigned URL
final presignedResponse = await _dio.post('/claims/documents/presign', data: {
'contentType': 'image/jpeg',
'fileSize': compressed!.lengthSync(),
});
final uploadUrl = presignedResponse.data['uploadUrl'] as String;
final documentId = presignedResponse.data['documentId'] as String;
// 3. Upload directly to S3
await Dio().put(
uploadUrl,
data: compressed.openRead(),
options: Options(
headers: {
'Content-Type': 'image/jpeg',
'Content-Length': compressed.lengthSync(),
},
),
);
return documentId; // Return ID for claim association
}
}Line-by-line walkthrough
- 1. FlutterSecureStorage with AndroidOptions(encryptedSharedPreferences: true) uses Android's EncryptedSharedPreferences backed by the Android Keystore — hardware-backed on devices with a secure element.
- 2. IOSOptions with first_unlock_this_device means tokens are accessible after the first device unlock but not while the device is locked — balancing security with background refresh capability.
- 3. Future.wait saves both tokens atomically — avoids a state where access token is saved but refresh token write fails.
- 4. The Dio interceptor adds X-Idempotency-Key on every mutating request — generated fresh per request so retries use the same key (stored in a retry handler, not shown here for brevity).
- 5. onError intercept for 401 attempts a token refresh before failing — this handles silent token refresh without the user seeing a login screen for normal session continuation.
- 6. After refresh, handler.resolve(await dio.fetch(error.requestOptions)) retries the original request with the new token — the original caller's Future receives the retried response transparently.
- 7. PaymentStateMachine encodes valid state transitions as a const Map — invalid transitions are caught before the API call, preventing invalid state mutation requests.
- 8. ClaimDocumentService uploads via presigned URL: the app server issues a time-limited, scope-limited S3 URL; the file never passes through the app server, reducing load and keeping PII data out of application logs.
Spot the bug
class AuthInterceptor extends Interceptor {
bool _isRefreshing = false;
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
final newToken = await _refreshAccessToken();
if (newToken != null) {
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
final response = await Dio().fetch(err.requestOptions);
handler.resolve(response);
return;
}
}
handler.next(err);
}
}Need a hint?
Multiple concurrent API calls all return 401 simultaneously. What happens and how do you fix it?
Show answer
Bug: Multiple concurrent 401 errors trigger _refreshAccessToken() simultaneously — resulting in multiple parallel refresh token requests to the server. Most servers invalidate the refresh token after first use (rotation), so only the first refresh succeeds; the rest fail with invalid_token, logging the user out incorrectly. Fix: use the _isRefreshing flag and a queue. When _isRefreshing is true, queue subsequent 401 retries and wait for the single in-flight refresh to complete, then retry all queued requests with the new token. Pattern: use a Completer<String> stored as a field — first 401 starts the refresh and sets the completer; subsequent 401s await the same completer. Also: new Dio() inside the interceptor creates a new instance without interceptors — use the original dio instance reference (passed to the interceptor constructor) to avoid infinite loops when retrying.
Explain like I'm 5
Building a fintech app is like being a bank manager. You need super-strong locks on every door (encryption and secure storage), cameras recording everything (audit logs), guards checking IDs carefully (strong auth with BankID), and emergency plans for when things go wrong (saga pattern). If the electricity goes out mid-transaction, you need to know exactly what happened and put everything back exactly as it was.
Fun fact
The average fintech data breach costs 5.9 million USD — nearly double the cross-industry average. This is why fintech apps undergo the most rigorous security architecture reviews, and why senior fintech interviews always include security design questions.
Hands-on challenge
Design the complete auth and payment flow for a claims app: (1) BankID authentication flow with JWT issuance. (2) Secure token storage implementation. (3) Document upload flow with presigned URLs and client-side compression. (4) Payment state machine (define all states and valid transitions). (5) Error handling for: network timeout mid-payment, session expiry during document upload, payment gateway failure. (6) What audit data would you log for a claim submission?
More resources
- OWASP Mobile Security Testing Guide (OWASP)
- flutter_secure_storage package (pub.dev)
- Saga pattern for distributed transactions (microservices.io)
- PCI DSS mobile payment guidelines (PCI SSC)
- Dio HTTP client for Flutter (pub.dev)