Problem RestatementProblem
Capital One asked: design a secure, highly available backend for a mobile banking app. Customers log in, see accounts and balances, browse transaction history, transfer money (between their own accounts and to others), pay bills, and get notifications. The system must be correct with money, secure against fraud and account takeover, and always available.
RequirementsRequirements
- Login with strong authentication (password/biometrics + MFA, trusted devices).
- Accounts, balances (available vs current), and transaction history with search.
- Transfers: internal (instant), external (ACH/wire, takes time), scheduled and recurring. Bill pay.
- Push notifications (large transactions, low balance, login from a new device).
- Audit trail. 99.99% availability.
ArchitectureArchitecture
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
APP["Mobile app"] --> GW["API gateway - TLS, WAF, rate limits"]
GW --> AUTH["Auth service - MFA, device binding, sessions"]
GW --> BFF["Mobile BFF"]
BFF --> ACC["Accounts service"]
BFF --> TX["Transactions service"]
BFF --> TRF["Transfers service"]
TRF --> RISK["Fraud / risk scoring"]
TRF --> LED[("Ledger - double entry")]
TRF --> RAIL["Payment rails - ACH, wire, RTP"]
ACC --> CORE["Core banking system"]
LED --> K[("Events")]
K --> NOTIF["Notifications"]
K --> TXDeep Dive — Logging in without making it easy to break inDeep dive
Customers open the app several times a day and will not type a password each time. The auth design has to make that convenient without making a stolen phone or a stolen token into a drained account.
Password on every login, long-lived session
The user types a password; the server returns a session token that stays valid for weeks.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
PW["Password typed on a phone keyboard"] --> WEAK["Short, reused, and shoulder-surfable"]
PW --> TOK["Long-lived session token"]
TOK --> STORE["Stored on the device"]
STORE --> STEAL["Extracted from a backup or a compromised device"]
STEAL --> USE["Works from anywhere, for weeks, with no further checks"]The password is the weakest credential available on a phone, and the token it produces is a bearer credential with a long life — anyone who copies it is the customer, from any device, until it expires.
Biometrics unlocking a stored token
Face or fingerprint unlocks a token held on the device. Convenient, and no password typed in public.
The user experience is right and the underlying credential has not changed: the biometric gates local access to a bearer token that still works anywhere once extracted. The security improved less than it appears, because the thing being protected is the same thing.
A device-bound key, short-lived tokens, and step-up for risk
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
ENROL["Device registered after full MFA"] --> KEY["Key pair generated in the secure enclave - private key cannot leave"]
BIO["Biometric unlock"] --> SIGN["Device signs the auth challenge"]
KEY --> SIGN
SIGN --> AT["Short-lived access token - minutes"]
AT --> RT["Refresh token, rotated on every use"]
RT --> REUSE{"Old refresh token reused?"}
REUSE -->|"yes"| REVOKE["Theft detected - revoke the whole family"]
ACT["New payee, large transfer"] --> STEP["Step-up: re-authenticate"]
NEW["New device or unusual location"] --> STEP- The key is bound to the device and cannot be exported. Authentication becomes a signature from that specific phone, so a copied token is useless without the hardware — which is what the previous rung could not offer.
- Short-lived access tokens with rotating refresh tokens. Rotation gives theft detection for free: if an old refresh token is presented, two parties hold it, so the entire token family is revoked.
- Step-up authentication for risky actions. Viewing a balance and adding a new payee do not deserve the same assurance. Re-authenticate for the actions that move money to somewhere new.
- Treat new devices and locations as signals, not just for blocking but for deciding when to ask for more.
The principle worth stating: bind the credential to the device, keep it short-lived, and raise assurance with the value of the action. Convenience then comes from the common case being cheap, not from the guarantee being weak.
Security and Compliance
- Encryption everywhere, secrets in an HSM/KMS, and PII masked in logs.
- Rate limiting and bot detection on login, and account lockout with safe recovery.
- Every money movement and profile change is in an append-only audit log.
- Data retention per regulation. Customer notifications for sensitive changes (new device, new payee).
Availability
- Stateless services, multi-AZ, active-active across two regions for read paths. The ledger uses a strongly consistent database with synchronous replication within the region and a failover plan.
- If core banking is down: show cached balances ("as of"), accept transfers into a queue where allowed, and clearly show pending status.
- Pre-scale for payday and month-end peaks.
Wrap-UpWrap-up
Put strong device-bound authentication with step-up MFA in front of a mobile BFF that talks to accounts, transactions and transfers services. Make every transfer idempotent, fraud-checked and recorded in a double-entry ledger (instantly for internal transfers, with holds and tracked states for external rails), and feed history and notifications from ledger events. Mirror core banking data into read-optimized stores, keep a full audit trail, and design for multi-region availability with graceful behavior when core systems are slow.