The Payment Key Anyone Could Spend
A live spend-authorizing key shipped in a crypto-neobank's web bundle let anyone sponsor transactions. Here is how we found it and how to close it.
The setup
The target was a crypto-fiat neobank: a company that issues spendable cards on top of account-abstraction smart accounts, so a user can hold crypto and spend it like a bank balance. The interesting part of the architecture is the plumbing that hides blockchain mechanics from the user. Account abstraction lets a wallet behave like a normal app - no seed phrases in the user’s face, no need to hold a network’s native token just to move funds.
That plumbing runs on sponsored transactions. Somebody has to pay the network fee so the user does not have to, and that “somebody” is a paymaster. A paymaster is a funded service that signs off on covering a transaction’s gas. It is, in effect, a wallet that pays for other people’s transactions. Anything that lets an outsider direct that wallet is money.
Recon
Working black-box, with no account, we did the most basic thing there is: we loaded the web application and read the code it shipped. Every web app hands the browser a JavaScript bundle, and that bundle is fully readable to anyone who wants to look. Minification is not protection; it is a formatting choice. We pulled the bundle and read it.
We were reading it for one class of thing in particular. Client bundles frequently carry live third-party keys - analytics keys, feature-flag keys, and, more often than anyone would like, keys that authorize real actions. Front-end code that talks to a service needs the credential to talk to it, and under deadline that credential often gets pasted straight into the source rather than proxied through a backend.
The hypothesis
The hypothesis followed directly from the architecture. This product depends on a paymaster to sponsor transactions. The front end has to trigger sponsorship somehow. If the sponsorship call is made from the browser rather than from a backend the company controls, then the key that authorizes it has to be in the browser too - which means it is public.
So we went looking for a paymaster provider key in the bundle, and for any sign of a server-side policy that would limit what such a key could do. The first would be bad on its own. The first without the second would be critical.
What we tried
We found the key. Hardcoded into the public web bundle was a live key for the neobank’s account-abstraction paymaster provider - the credential that authorizes gas sponsorship. It was not behind a backend. It was in the shipped source, downloadable by anyone.
A key being present does not by itself prove it is dangerous. A well-run setup restricts what a client-side key can do with a server-side policy: this key may sponsor only transactions that match our own operations, from our own contracts, within these limits. So we tested for that policy the only honest way - by asking the paymaster to sponsor a transaction it should have refused.
We constructed a sponsorship request for a sender account we controlled - not one of the neobank’s users, an attacker-controlled address - and asked the paymaster to cover it. It signed. We repeated the request across multiple blockchain networks the provider served and got sponsorship signatures back on 9 of them. There was no policy scoping the key to the company’s own operations. The key would sponsor an arbitrary sender’s transactions.
What we deliberately did not do matters. Holding valid sponsorship signatures for an attacker-controlled sender across 9 networks is proof of the exposure. Broadcasting one of those transactions would have actually spent the funds behind the paymaster. We did not broadcast. We obtained the signatures, confirmed they were valid and unrestricted, and stopped there.
What we found
The finding was a spend-authorizing key with no spend policy, shipped to the public.
1. Read the public web bundle -> extract the live paymaster provider key.
2. Build a sponsorship request for an attacker-controlled sender.
3. Call the paymaster with the harvested key.
<- valid sponsorship signature returned.
4. Repeat across networks -> signatures returned on 9 of them.
5. (Not performed) broadcast a sponsored transaction -> would spend
the funds backing the paymaster.
The severity is in what the key authorized. This was not a read key or an analytics token. It authorized spending. With it, an anonymous party could obtain gas-sponsorship signatures for transactions they controlled, across every network the paymaster funded, and by doing so abuse the sponsored-transaction system to drain the funds tied to it. The neobank had funded that paymaster to cover its own users’ fees. The exposed key let anyone route those funds to cover their own activity instead.
Why it happens
Nobody set out to publish a spend key. This is what convenience under deadline looks like.
Account-abstraction SDKs make it easy to call a paymaster directly from the front end - that is often the documented quickstart path. A fallback or default key gets hardcoded so the flow works during development, and it ships, because the sprint’s goal was a working wallet, not a hardened key-handling story. The server-side spend policy - the piece that would make a client-visible key safe by refusing to sponsor anything but the company’s own operations - is a separate step that is easy to defer and easy to forget, because everything works without it.
And the durable truth underneath: anything in the bundle is public. Obfuscation, environment-variable inlining at build time, minification - none of it changes that a determined reader gets the value. A key that authorizes spending has no safe home in code that ships to a browser.
For developers
If you build on account abstraction, or ship any spend-authorizing credential, four controls close this:
- Never ship a spend-authorizing key to the client. A key that can move funds or sponsor transactions belongs on a server you control. The browser calls your backend; your backend holds the key and calls the provider. The key never leaves your infrastructure.
- Enforce a server-side spend policy scoped to your own operations. The paymaster should sponsor only transactions that match your contracts, your senders, and your limits. Configure the policy at the provider so that a request for an arbitrary sender is refused even if the key is known.
- Rotate any key that has been exposed. A key that has shipped in a bundle is compromised from the moment it shipped. Rotate it, and treat the old value as burned regardless of whether you have seen it abused.
- Scan build artifacts for secrets in CI. Run a secret scanner over the bundle you are about to deploy, not just over your source tree. A build step can inline a value that never appears in a grep of the repository.
And one detection step: review your paymaster’s sponsorship logs for senders that are not your users. Sponsored transactions for addresses you do not recognize are the signature of an abused key.
The takeaway
A key is only as safe as the policy behind it, and a key in a browser bundle is public. This one authorized spending and carried no server-side policy, so shipping it to the client handed anyone the ability to sponsor their own transactions across 9 networks and drain the funds meant for the company’s users. The account-abstraction plumbing that makes a crypto wallet feel like a bank app runs on a funded paymaster, and a funded paymaster with an unscoped, exposed key is an open wallet.
This teardown is one instance of a pattern we see repeatedly. We wrote up the general case, and how to defend against it, in The Secret in the Page Source.
Frequently asked
What is a hardcoded credential in a client bundle?
What is a paymaster key and why does exposing it matter?
How do you keep a spend-authorizing key out of the client?
This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.
How it works →

