case-study-extra

 

case study · Extra · software engineer, core platform · typescript, aws cdk, cognito

Changing the locks with everyone still inside

Extra is a debit card that builds credit from everyday spending: connect a bank account, Extra fronts your purchases against a limit set by your balance, repays itself the next business day, and reports the month's total to the bureaus as creditworthy payments. Nearly every request in that product is authenticated — and every one of them went through a single API and a single database. As a member of the Platform Team, I built the service that replaced it.

Why it had to move

Three reasons, and only one of them was about speed.

Scale. All authentication and authorization lived in one API. Because nearly every request to the product is authenticated, nearly every request meant a network call to that service and a query against the production database behind it. The whole product's ceiling was that one service's ceiling.

Security. The authentication was hand-rolled, and the second factor was SMS. A one-time code sent by text is only as strong as the assumption that nobody else can reach the member's phone — and for a product holding bank connections and spending history, that assumption doesn't survive contact with someone willing to call and pretend to be us.

The team. Engineering had grown fast and everyone was working in the same monorepo, standing on each other. Splitting into services each team could own was as much an organisational fix as a technical one.

Replacing any of that in isolation would have been ordinary work. Replacing it while people were using their cards was the actual problem.


what i built

An authentication and authorization microservice in TypeScript, with its infrastructure defined in AWS CDK. Cognito was chosen before I picked up the work and User Pools became the new source of truth for identity, with the goal of eventually deprecating the old API's auth endpoints behind it.

Most of the interesting decisions live in the stack definition rather than in application code — which is what infrastructure-as-code does to a project. The security posture becomes something you review in a pull request.

Four decisions in there carry most of the argument.

signInAliases · EMAIL_ONLY · phoneNumber optional

Email replaces the phone as the spine of identity. Three settings, and together they retire the SMS attack — a stolen or socially engineered phone number stops being a path into someone's financial data.

preventUserExistenceErrors: true

A failed sign-in can no longer tell an attacker whether the account exists. For a card product that means nobody can use the login form to confirm who's a member.

authFlows: { userSrp: true }

Secure Remote Password. The password itself never crosses the network.

removalPolicy: RemovalPolicy.RETAIN

Deleting a Cognito user pool deletes every user in it. RETAIN means a stack teardown can't take the identity directory with it — one line, and it's the difference between a bad afternoon and an unrecoverable one.

Plus explicit read and write attribute allowlists, so the client can only touch the fields it has been granted rather than the whole profile.


decision 1 — how members would cross

Password hashes can't be lifted from a hand-rolled system into Cognito, so there were two ways across: migrate people silently at their next sign-in, verifying against the old system and writing them into the new one — or force everyone to reset.

Decision: Force the reset. Email every member a deep link, they set a new password in the new system, and land back in the app.
Because:
A forced reset is simpler and safer to build, and it costs you a percentage of your members permanently. Lazy migration would have meant running the weakness alongside the fix for months. A forced reset closes it on the day of cutover.
Cost: Anyone who doesn't complete the reset is locked out until they do. On a credit-building product that isn't ordinary churn — a member who stops swiping stops generating the monthly report to the bureaus, which is the entire thing they signed up for. The migration's completion rate was the product's completion rate.

decision 2 — cognito

Decision: Cognito User Pools as the new source of truth for identity, provisioned with CDK in TypeScript.
Because: The bottleneck was never the user directory — it was the round trip. Every authenticated request had to ask one service, which asked one database, whether the caller was who they said they were. Cognito replaces that question with a signed token any service can verify on its own, against a public key, with no network call and no query. We were splitting the monolith into services at the same time, and this was the piece that let each of those services authenticate independently instead of all leaning on the same API. The rest of it was declining to hand-roll credential storage a second time. We already knew what our own implementation had cost us.
Cost: Cognito is opinionated in ways you inherit. Anything custom goes through Lambda triggers, the hosted UI resists real branding, and the exit is expensive — user pools are much easier to enter than to leave. Choosing it was choosing to accept those constraints in exchange for not owning credential storage.

Where the infrastructure became an interface

The migration only worked if members didn't experience it as a security incident. Anything that looks like an unexpected password change on a financial product reads as a breach, and people respond to that by leaving.

what happened

The cutover completed. Cognito became the single source of truth for identity — the old auth endpoints were no longer primary. We watched the migration on a custom dashboard built for the migration, so the decision to switch the old endpoints off was made against a number rather than a feeling.