Payment & financial application security
Security boundaries in two payment apps
Two of my projects handle money off-chain: Protected Pay's business API and dashboard, and Find My Savings, which connects to a user's bank through Plaid. Both have a few spots where a mistake would be expensive. The most interesting one is a bug my own tests caught, so I'll start there.
A 503 that leaked whether a payment existed
Payment and dispute pages in Protected Pay only load for the people in the payment. Everyone else gets a 404 that looks the same whether the payment is real or made up. That's on purpose, so the page can't be used to check whether a given payment exists.
Then it quietly broke. When the on-chain membership read failed, a real payment returned a 503 while a missing one still returned 404. That gap was enough to tell the two apart. I fixed it so a non-party always gets the same not-found answer, even when the chain read fails, and added tests that force the read to fail and check the two responses are identical.
it('an unrelated signed-in wallet gets exactly what a missing payment gets', async () => {
const token = await s.tokenFor(s.stranger);
const unrelated = await s.access(token);
expect(unrelated).toEqual({ kind: 'unavailable', wallet: s.stranger.address });
expect(await s.access(token, s.missing)).toEqual(unrelated);
});
it('with the chain unreachable, a non-party and a missing payment still look the same', async () => {
s.world.state.down = true; // force the membership read to fail
expect(await s.access(token)).toEqual(await s.access(token, s.missing));
});Webhooks that can't be pointed at your own network
A business can register a URL for Protected Pay to POST events to. That's a classic SSRF setup: point the URL at an internal address and the server makes requests for you. So before every delivery the hostname gets resolved again, and the request is dropped if it resolves to anything private: loopback, link-local, the cloud metadata address, unique-local IPv6, or an IPv4-mapped IPv6 version of any of those. Re-resolving on every send is what catches DNS rebinding, where a name is public when you register it and flips to private later. Redirects aren't followed, there's a timeout, and the test runs a whole table of private answers and checks nothing goes out.
// The name now points somewhere private (rebinding): the next delivery is refused.
for (const answer of [
['127.0.0.1'], ['169.254.169.254'], ['::1'], ['fd00::1'],
['::ffff:192.168.0.1'], null, [],
]) {
s.dns.set('hooks.shop-one.com', answer);
const before = s.sent.length;
await s.event(b.business, 'payment.created');
await s.run();
expect(s.sent.length).toBe(before); // nothing sent
}API keys you can't walk back
A business key is shown once. The server stores only an HMAC of it under a rotating pepper, compares in constant time, and rate-limits per key. Keys can be revoked, or rolled with a grace period so a rotation doesn't cause an outage, and a test key won't work against a live environment. Requests that look like they came from a browser (they carry an Origin, a Sec-Fetch-Site, or cookies) are refused outright, because a server-to-server key shouldn't be called from frontend code, and that's usually how one leaks.
Find My Savings: verifying Plaid before trusting it
Find My Savings holds a live bank connection through Plaid, so the two things worth protecting are the public webhook and the bank access token. Plaid signs each webhook with a JWT. The handler fetches Plaid's JWKS, picks the key by id, and verifies the RS256 signature with Web Crypto before reading a single field. It also checks issuer, audience, and expiry, and when Plaid includes a body checksum it recomputes a SHA-256 of the raw body and compares, so a valid signature can't be reused over a swapped payload.
The access token never touches the browser. The short-lived public token the client gets is exchanged server-side for the long-lived access token, which is stored server-side under a service role and never sent back to the client. Provider credentials stay in a server secret store.
These are built, tested controls in my own apps, checked against the source. The existence leak was a real bug in my own code that I found and fixed. Nothing here is a finding in someone else's system. No external audit, and no guarantee.