How I Actually Review a Fintech Architecture and Threat Model It
This is the long one. It is the process I run when a client hands me an architecture diagram and a payments platform instead of a single URL. No client is named, every diagram is a generic reference shape, and every hostname is
example.com. Grab a coffee.
A penetration test asks "can I break this running system." An architecture review asks a harder question first: "where is this system going to break, before a single packet is sent." For most apps the second question is a nice to have. For a fintech platform it is the whole job, because the assets are money, the money movement is irreversible, and a single missing authorization check does not leak data, it moves funds.
I have reviewed payment platforms on all three of the clouds people actually ship them on, and the interesting thing is how little the cloud matters to the attack surface and how much the shapes rhyme. The managed service names change. The trust boundaries do not. So this post does two things. First it shows you the same payment platform drawn three ways, on AWS, GCP, and OCI, so you can see the rhyme. Then it walks component by component through what I actually open the hood and check, because that is the part nobody writes down.
Step zero: stop looking at the diagram they gave you
The first artifact a client hands over is an architecture diagram that was drawn to explain the system to humans. It is optimized for clarity, which means it is a lie of omission. It shows the happy path and hides the backups, the admin jump box, the CI runner with production credentials, the analytics pipeline quietly reading a replica of the payments database, and the third party that has a standing VPN into the network.
So I redraw it. Every architecture review I do starts by rebuilding the diagram as a data flow, then drawing the trust boundaries on top: the lines where data crosses from something the company controls to something it does not, or from one privilege level to another. The boundaries are the review. Almost every real finding is a missing or wrong control on a boundary crossing, and the boundaries are exactly what the pretty diagram leaves out.
Here is the reference payment platform I will use throughout. A customer facing app and a partner API sit in front of a set of services that authorize and capture payments, talk to a ledger, publish events onto a bus, cache hot data, and call out to external payment processors and a KYC provider. Simple to say. Every arrow is a place to get it wrong.
The same platform, three clouds
On AWS
flowchart TB
U["Customer app / Partner API"]
subgraph edge["Edge (public trust boundary)"]
WAF["WAF + CloudFront"]
ALB["ALB / API Gateway"]
end
subgraph vpc["VPC private subnets"]
AUTH["Auth service<br/>Cognito / OIDC"]
PAY["Payments service<br/>ECS / EKS"]
LEDG["Ledger service"]
MSK["Amazon MSK<br/>(Kafka)"]
RED["ElastiCache<br/>(Redis)"]
RDS[("RDS / Aurora<br/>Postgres")]
end
subgraph data["Secrets & keys"]
SM["Secrets Manager"]
KMS["KMS"]
end
EXT["Payment processor / KYC<br/>(third party)"]
U -->|TLS| WAF --> ALB
ALB --> AUTH
ALB --> PAY
PAY --> LEDG
PAY --> MSK
PAY --> RED
PAY --> RDS
LEDG --> RDS
PAY -->|"outbound TLS + signature"| EXT
PAY -.-> SM
RDS -.-> KMS
On GCP
flowchart TB
U["Customer app / Partner API"]
subgraph edge["Edge (public trust boundary)"]
AR["Cloud Armor + Cloud CDN"]
LB["Global HTTPS LB / Apigee"]
end
subgraph vpc["VPC (private)"]
AUTH["Auth<br/>Identity Platform / OIDC"]
PAY["Payments<br/>GKE"]
LEDG["Ledger service"]
PS["Pub/Sub or<br/>Confluent (Kafka)"]
MEM["Memorystore<br/>(Redis)"]
SQL[("Cloud SQL<br/>Postgres")]
end
subgraph data["Secrets & keys"]
SEC["Secret Manager"]
CKMS["Cloud KMS"]
end
EXT["Payment processor / KYC<br/>(third party)"]
U -->|TLS| AR --> LB
LB --> AUTH
LB --> PAY
PAY --> LEDG
PAY --> PS
PAY --> MEM
PAY --> SQL
LEDG --> SQL
PAY -->|"outbound TLS + signature"| EXT
PAY -.-> SEC
SQL -.-> CKMS
On OCI
flowchart TB
U["Customer app / Partner API"]
subgraph edge["Edge (public trust boundary)"]
WAF2["OCI WAF"]
LB2["Flexible LB / API Gateway"]
end
subgraph vcn["VCN private subnets"]
AUTH["Auth<br/>IDCS / OIDC"]
PAY["Payments<br/>OKE"]
LEDG["Ledger service"]
STR["OCI Streaming<br/>(Kafka-compatible)"]
CACHE["OCI Cache<br/>(Redis)"]
ADB[("Autonomous DB /<br/>MySQL HeatWave")]
end
subgraph data["Secrets & keys"]
VLT["OCI Vault"]
KEY["Vault KMS"]
end
EXT["Payment processor / KYC<br/>(third party)"]
U -->|TLS| WAF2 --> LB2
LB2 --> AUTH
LB2 --> PAY
PAY --> LEDG
PAY --> STR
PAY --> CACHE
PAY --> ADB
LEDG --> ADB
PAY -->|"outbound TLS + signature"| EXT
PAY -.-> VLT
ADB -.-> KEY
Look at the three side by side and the point makes itself. MSK, Confluent, and OCI Streaming are the same Kafka shaped boundary. ElastiCache, Memorystore, and OCI Cache are the same Redis. RDS, Cloud SQL, and Autonomous DB are the same datastore. The managed service is different, the questions I ask are identical. So let me ask them.
The edge and the gateway
The public trust boundary is where the internet becomes your problem. What I check here is less about the WAF, which everyone has, and more about what slips past it or around it.
- Is there a path to the origin that skips the edge entirely? A load balancer or service with a public IP that is not behind the WAF is a bypass of everything the WAF does. On every cloud I look for the service that got exposed directly because someone was debugging and never undid it.
- Does the gateway terminate TLS and then talk plaintext internally with no re authentication? Internal is not a trust level. If a foothold anywhere in the VPC can call the payments service directly, the edge controls are theatre.
- Are partner API and customer API actually separated, or do they share a backend that trusts a header the gateway was supposed to set? Header spoofing at this boundary is a classic. If
X-Internal-AuthorX-User-Idis trusted by the backend and settable by a client who reaches the backend directly, the whole authorization model collapses.
OAuth and OIDC: where identity quietly breaks
Auth is the boundary that decides who everyone is, so it earns the most time. Fintech almost always uses OAuth 2.0 and OIDC, and the failures are consistent. This is my checklist.
- Token validation, fully. Does every service that accepts a JWT validate the signature, the
iss, theaud, and the expiry, every time? The recurring bug is a service that decodes the token and trusts the claims without verifying the signature, or acceptsalg: none, or accepts a token minted for a different audience. Test with a token that is valid for service A against service B. - The flow itself. Authorization Code with PKCE is the answer for anything public facing. If you find the Implicit flow, or Code without PKCE, or an app embedding a client secret it cannot keep, that is a finding. Check
redirect_urivalidation hard, because loose matching (prefix instead of exact, open path, wildcard) turns into token theft through an open redirect. - Scopes and audience as authorization, not decoration. A token scoped to read balances should not move money. I test whether the payments service actually enforces scope, or whether any valid token from the tenant is treated as fully privileged once it is past the door.
- Refresh tokens and revocation. Are refresh tokens rotated on use, bound to the client, and actually revocable? Does logout invalidate server side, or just drop the cookie? A refresh token that survives a password reset is a persistence mechanism handed to the attacker.
- The machine identities. Service to service auth is still auth. Client credentials grants with over broad scopes, shared service accounts, and long lived tokens with no rotation are the norm and they are where lateral movement lives. Ask what the payments service can do if its own token leaks, and you will usually find the answer is "everything."
- Consent and multi tenancy in the token. In a partner API, the token has to carry which tenant the caller is allowed to act for, and every downstream must honor it. A token that says "partner X" but is accepted to act on partner Y's accounts is cross tenant fund movement. This is the single most dangerous OAuth failure in fintech.
The PayloadsAllTheThings JWT and OAuth notes are the concrete payload catalogues I run through here rather than reinventing the tests each time.
Kafka, the bus everyone trusts too much
Kafka (MSK, Confluent, OCI Streaming, all the same) tends to sit in the middle of the platform carrying the most sensitive events, payment authorized, payout initiated, KYC passed, and it tends to be the least locked down because it is "internal." That combination is the finding.
What I check on the event bus:
- Authentication and authorization on the brokers. Is mTLS or SASL actually enforced, or can anything that reaches the broker port produce and consume? An unauthenticated Kafka carrying payment events is a read and write tap on the money pipe. Check the ACLs per topic, not just that auth exists. Frequently auth is on but every principal has read and write to every topic.
- Who can produce to the topics that trigger money. If the payout service consumes a
payout.requestedtopic and acts on it, then anyone who can produce to that topic can request payouts. Producing is the injection point. I look for whether a compromised low value service can write to a high value topic. - What is in the messages. Full PANs, tokens, or PII sitting in plaintext in a topic that gets retained for seven days is a data exposure with a long tail. Check retention and encryption of the payloads, not just the transport.
- Consumer trust. Does the consumer treat the event as authoritative, or re verify against the system of record? A consumer that reads
amountfrom the event and moves that amount, without re fetching the authoritative transaction, will happily process a forged or replayed event. - Replay and ordering. Are events idempotent on the consumer side? Kafka gives you at least once delivery, so a consumer that is not idempotent will double process on a normal redelivery, no attacker required. And if an attacker can replay an old
payout.approved, does it fire again?
Redis, the cache that becomes the source of truth
Redis (ElastiCache, Memorystore, OCI Cache) starts as a cache and slowly accretes responsibility until it is holding sessions, rate limit counters, locks, and sometimes balances. The security posture rarely keeps up with the responsibility.
- Is it authenticated and network isolated. An open Redis with no
AUTHand a reachable port is one of the most reliably catastrophic misconfigurations there is. Even inside a VPC, "inside the VPC" is not authentication. Check that it requires credentials and that only the services that need it can reach it. - What lives in it. If sessions live in Redis, then read access to Redis is session theft for every user at once. If it holds a
balance:useridthat the app trusts, then write access to Redis is minting money. I map what the app reads from Redis and treats as true, because that is the blast radius of a Redis compromise. - Locks and idempotency keys. Fintech uses Redis for distributed locks and idempotency ("has this payment already been processed"). If the lock or the idempotency key can be evicted under memory pressure, or has a TTL shorter than the operation, the double spend window opens on its own. Check the eviction policy against what is being stored, because a
volatile-lrupolicy silently dropping idempotency keys is a race condition with a schedule. - Lua and modules.
EVALand loadable modules can be an RCE path on a reachable Redis. If the app exposes any way to influence Lua scripts, that is worth a hard look.
The databases
The datastore is the system of record, and in fintech it is the ledger. Everything else is a cache of it or a message about it.
- Encryption is table stakes, access is the question. Encryption at rest via KMS is on by default on all three clouds and stops exactly one threat, a stolen disk. It does nothing against a compromised app credential, which is the actual threat. So I focus on who and what can reach the database and with what rights.
- One credential to rule them all. The recurring finding is a single database user with full read and write that every service shares. Compromise any service and you own the ledger. I look for per service least privilege, read replicas for the read heavy analytics path, and whether the payments service can actually
DROPor onlySELECTandINSERTwhat it needs. - The ledger's integrity model. Is money movement append only and double entry, or can a balance be
UPDATEd directly? A mutable balance column is a fraud waiting for a bug. The strongest ledgers are immutable event logs where the balance is derived, never written. - Backups and replicas as a side door. The production database is hardened. The nightly backup sitting in a storage bucket, or the read replica feeding the data warehouse, often is not. A replica of the payments DB with looser access is the same data with a weaker lock. Check the copies, not just the original.
- Injection, still. Parameterization is standard but not universal, especially in reporting and admin tooling written in a hurry. The PayloadsAllTheThings SQL Injection catalogue is what I run against any endpoint that smells like it builds a query from input.
Third party integrations, the trust you did not write
Payment processors, KYC providers, and banking partners are code you depend on and did not write, connected by two things attackers love: outbound calls and inbound webhooks.
- Webhooks are an unauthenticated door into your ledger. The processor calls your endpoint to say "payment succeeded." If that endpoint does not verify the provider's signature over the raw body, an attacker posts their own "payment succeeded" and gets goods for free. I check signature verification, timestamp and replay protection, and, most importantly, whether the handler trusts the amount and ID from the webhook body or re fetches the authoritative object from the provider. Trusting the body is how "I paid one cent, the webhook claims ten thousand dollars of value" happens.
- Outbound request signing and pinning. When your service calls the processor, is the request signed and the response validated? Can an attacker who controls a network hop or a DNS answer impersonate the processor? SSRF elsewhere in the app that can reach the outbound integration is a way to forge these calls from the inside.
- The standing access. Partners frequently get a VPN, an API key, or an IAM role into your environment. That access is part of your attack surface now. I inventory every third party's standing access and ask what a compromise of that partner does to you, because your security is now the minimum of yours and theirs.
- Secrets for the integrations. The API keys and signing secrets for these providers are the crown jewels of the integration. They belong in the secrets manager with rotation, never in code, config maps, or environment variables baked into an image.
Now threat model it
With the data flow drawn and the components understood, the threat model is fast, because the boundaries are already on the page. I walk each boundary crossing and apply STRIDE, keeping only the threats that are real for this system, then rank by impact times reachability so the output is a sorted list of things to fix rather than a wall of theory.
flowchart LR
A["Internet"] -->|"B1"| B["Edge / gateway"]
B -->|"B2"| C["Auth / OAuth"]
C -->|"B3"| D["Payments service"]
D -->|"B4"| E["Kafka bus"]
D -->|"B5"| F["Redis"]
D -->|"B6"| G["Ledger DB"]
D -->|"B7"| H["Third party / webhooks"]
A worked slice of the ranked output looks like this.
| Boundary | STRIDE lens | Threat | Rank |
|---|---|---|---|
| B2 partner to auth | Spoofing | Token minted for tenant X accepted to act on tenant Y | Critical |
| B7 processor webhook | Tampering | Forged or replayed webhook credits an account | Critical |
| B4 service to Kafka | Elevation | Low value service can produce to payout.requested |
High |
| B5 service to Redis | Tampering | Idempotency key evicted under memory pressure, double spend | High |
| B6 service to ledger | Elevation | Shared DB user can UPDATE balances directly | High |
| B3 gateway to payments | Spoofing | Backend trusts X-User-Id header settable if reached directly |
High |
| B1 internet to edge | Info disclosure | Origin reachable directly, bypassing WAF | Medium |
That table is the deliverable. Not the diagram, not the STRIDE grid, the ranked list of specific, reachable threats with the boundary each one lives on, so the team knows exactly what to fix and in what order.
The through line
The cloud is a detail. Whether the bus is MSK or Pub/Sub or OCI Streaming, the question is who can produce to the topic that moves money. Whether the cache is ElastiCache or Memorystore, the question is what the app trusts it to hold. Whether identity is Cognito or Identity Platform or IDCS, the question is whether a token for one tenant can act for another. Learn the boundaries and the managed service names stop mattering.
A fintech architecture review is not a checklist you run against a cloud. It is a map you draw of where trust changes hands, followed by a stubborn tour of every one of those handoffs asking the same rude question: what happens if the thing on the other side is lying. Do that thoroughly and you find the money moving bugs on a whiteboard, months before they would have found you in production.