
AWS Security: Building a Private Family Banking App with One Secret in an SSM Parameter.
I needed a family application to budget, run our transactions and I wanted full control of the process end to end. No more trusting random Banking apps with my bank passwords or information.
My wife and I have been thinking about paying Monarch Money about $8 a month to look at our own transactions. It’s a good app, cant complain really, But I’m a security engineer with an AWS account, a grudge against subscription pricing, and a long weekend, so I decided to use Fable to build us a replacement. It syncs our banks daily, runs budgets and recurring detection, ships as an iPhone app for her and an Android app for me, and costs around $3 a month all-in (most of which is the bank feed, not AWS).
The part I actually want to talk about is not the budgeting, its great to have my own budgeting app now , It’s the system holds exactly one secret worth stealing, and that secret can’t move money, financial security with read only.
The Threat Model is basically, a Couple’s Checking Account when you build a personal finance app, the bad version is the one where your server stores bank usernames and passwords. I never wanted to be one breach away from explaining to my wife why someone in another timezone is enjoying our checking account (she already questions my Pokemon card spending, I don’t need that meeting or why the pokemon budget bucket is over by 200% again).
ahh yes shiny cardboard such wow much value.
GoldFinch uses SimpleFIN Bridge for bank aggregation, which means the bank credentials live with the aggregator and my system receives a single read-only access URL. That URL is the entire secret inventory of the application. If an attacker gets absolutely everything I run, they get a read-only copy of transaction history. Embarrassing? Sure (the DoorDash frequency alone). Money gone? No. Designing the worst case to be “uncomfortable” instead of “catastrophic” was the cheapest and most secure part of the app.
One Parameter, One Key, One Reader
That access URL lives in a single SSM SecureString parameter, encrypted with a dedicated KMS key, readable by exactly one Lambda (the nightly sync job). It never reaches the phone apps, never appears in an environment variable, never gets logged. The sync Lambda reads it, pulls transactions over HTTPS, writes to DynamoDB, and that’s the entire data path.
Press enter or click to view image in full size
Got the diagram for the visual nerds among us.
Now a code review pass did point out that the CDK default key policy keeps the account-root statement, which delegates authorization to IAM. Translation: any principal in the account that ever gets a broad kms:Decrypt in an identity policy can read your crown-jewel secret, and your key policy will happily allow it. My “only the sync role” promise was a convention and not an actual security control.
1
2
3
4
5
6
7
8
9
10
11
12
// The fix: an explicit Deny that outranks every future overpermissive policy
key.addToResourcePolicy(new PolicyStatement({
sid: 'DenyDecryptExceptSyncRole',
effect: Effect.DENY,
principals: [new AnyPrincipal()],
actions: ['kms:Decrypt'], // ONLY decrypt. Deny kms:* and you
// brick your own key management. Ask me
// how I almost know. sigh
conditions: {
StringNotEquals: { 'aws:PrincipalArn': syncRole.roleArn },
},
}));Now the boundary is enforced at the resource, not by hopes and dreams of the absence of bad IAM elsewhere. A future debugging role with kms:Decrypt on star gets denied at the key level. Even an admin session running a casual get-parameter with decryption gets denied (hopefully), which is exactly the kind of friction you want around the one secret that matters.
No VPC, On Purpose
GoldFinch has no VPC, listen listen I HEAR you!, however every Lambda talks to AWS APIs and one HTTPS endpoint, so there are no private subnets, no NAT Gateway, no load balancer, and critically, no inbound network surface beyond CloudFront and an authenticated API Gateway. There is nothing to scan. The architecture diagram is four managed services and a table (my kind of network diagram, in that it isn’t one).
This is also the entire reason the bill stays near zero (This is a risk appetite decision again, that fulfills price constraints and risk) . A NAT Gateway is $32 a month for existing. An ALB is $16. One always-on RDS instance and you’ve lost to Monarch on price before serving a single request. The discipline only holds if it’s enforced by something stronger than memory, so the CDK app carries an Aspect that fails synthesis if anyone (me, or worse, an AI coding agent at 3am)
introduces a banned resource:
introduces a banned resource:
1
2
3
4
5
6
7
8
9
10
11
12
class CostGuardrailAspect implements IAspect {
visit(node: IConstruct): void {
if (node instanceof CfnNatGateway)
throw new Error('NAT Gateway: $32/mo to exist. Denied.');
if (node instanceof CfnLoadBalancer)
throw new Error('ALB: this app has two users. Two.');
if (node instanceof CfnDBInstance)
throw new Error('RDS: DynamoDB is right there.');
// The guardrail has caught exactly one of these in anger.
// Worth every line.
}
}The same trick guards security posture. cdk-nag runs as a second Aspect, and the IAM tests are written in the negative: assert the sync role has no GetItem, assert the API role has no Scan, assert nobody but sync can touch SSM.

Identity Comes From the Token, Never the Client
Auth is Cognito with passkeys, two users, self-signup disabled (the world’s most exclusive fintech). The interesting choice is a Pre-Token-Generation trigger that stamps a household claim into the access token. Every API handler derives the DynamoDB partition from that claim and only that claim. There is no request body, path parameter, or header anywhere in the system that selects whose data you’re reading. The client can ask for whatever it wants; the token decides what it gets.
The same principle shows up at write time in a way I haven’t seen written about much. The nightly sync and the humans both write to the same account records, so fields are explicitly owned: the bank owns balances and payees, the humans own category choices, notes, and account-type overrides. Sync writes are attribute-scoped updates that physically cannot touch user-owned fields. When my wife recategorizes a transaction at 9:00:02 and the sync lands at 9:00:05, her edit survives, not because of careful timing, but because the sync’s UpdateExpression doesn’t contain her fields. Huge win really
The Bill at the end of the day is my Security Boundary and my risk appetite.
Steady state lands around $3 to $4 a month, dominated by the $15-a-year bank feed. But the cost ceiling turned out to be a security instrument as much as a budget. Every architectural shortcut that adds attack surface (a VPC to hide in, an instance to patch, a load balancer to potentially misconfigure) also bills by the hour, so the guardrail that protects the wallet protects the perimeter for free. Smallest possible bill and smallest possible attack surface turned out to be the same shape and I am lucky for that.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article