Your client-side AI key is in 4 mobile screenshots already
2026-05-12 · Skelf-Research
A founder messaged us last month. They had shipped a beta iOS app the previous Tuesday. By Friday, OpenAI usage on their account had quintupled. By Saturday, it had quintupled again. The key was rotated on Sunday. On Monday, the new key was scraped from the next TestFlight build and the graph shrugged and kept climbing.
The naive position — “I’ll hide it in the binary, who would look?” — is not a security posture. It is a request for an apology email in three weeks. The depressing thing about that founder’s story is not that it happened. It is how few screenshots it took.
Screenshot one: a .ipa is a .zip
iOS apps are signed bundles. They are also .zip files. Anyone with a
jailbroken phone, a Mac with Xcode, or a TestFlight build and ten
minutes can pull the binary off the device and unwrap it. Inside is your
app bundle, your Info.plist, your JS assets if it’s a hybrid app, and
every string the linker happened to keep.
A first-pass extraction is one command:
strings MyApp.app/MyApp | grep -E "sk-[A-Za-z0-9]{20,}"
If your key is in the binary as a string, you have just published it.
This is not a sophisticated attack. It is grep. The first screenshot in
this article is whatever your screenshot tool happens to grab when you
paste a key into a terminal — it doesn’t matter, the point is that the
attacker doesn’t need anything except a working strings(1).
Screenshot two: the JS bundle
If your app is React Native, Capacitor, Expo, Cordova, or any Electron
variant, the JS is in there. Minified, sure. But your bundler probably
turned process.env.OPENAI_API_KEY into a literal at build time. Open
the JS in any editor with regex search. There it is. The second
screenshot is your bundled JS in any text editor, with the key
highlighted in the result pane.
People reach for obfuscation here. Don’t. JavaScript obfuscation is a speed bump. It costs you debugging ability and it costs an attacker about fifteen extra minutes with a beautifier. We have seen production apps where the “obfuscation” amounted to a single layer of base64. A 20-line script un-rolls it.
Screenshot three: a real HTTP capture
Suppose your build process is clever. Suppose the key isn’t in the bundle directly — instead, the app fetches it on launch from a “configuration” endpoint and stores it in memory. This buys you nothing. A proxy on the device (Charles, mitmproxy, Proxyman on macOS) shows the config endpoint’s response. The key is in the third screenshot, in the response body.
You can pin certificates. You can detect a debugger. These raise the cost from “fifteen minutes” to “an afternoon”. They do not move the attack from possible to impossible. And every defence you add to the client is a defence you have to maintain across iOS, Android, the browser, and Electron. The attacker only has to win once.
Screenshot four: the GitHub search
This is the one that hurts. Once a key leaks, it gets shared, sometimes
unintentionally. Someone opens an issue with a stack trace that
includes the request that was failing. The stack trace includes the
header. The header includes the key. The fourth screenshot is GitHub
code search for sk-.
Anthropic, OpenAI, and OpenRouter all run scanners on public GitHub. They will sometimes catch a key and notify you. They will not catch one in a private gist or a Slack DM or a Discord paste. Assume the leak is already public the moment it leaves your machine.
What “secure” actually means here
You cannot keep a secret on a device that the user — who is sometimes the attacker — controls. Every model that pretends otherwise eventually fails. The right move is to stop trying to keep a secret on the device and to ship a different thing entirely: a short-lived, fingerprint- bound, rate-limited token that, if leaked, expires before it matters.
That is the only honest position. It is also the position Perishable takes. The real key lives on a server you control. The client authenticates to that server with whatever auth you already have (or with the entropy / fingerprint check Perishable provides by default). The server issues a JWT with a TTL measured in minutes. The JWT is bound to the fingerprint, so a copy on a different browser is rejected.
A leaked token, in that world, is a fifteen-minute liability. A leaked
sk- key is a billing event.
”But I need to call OpenAI from the browser”
You do. Apps want to stream chat completions into a UI without a 500-millisecond round trip through a backend. The whole point of an AI SDK is that it feels native to the client. Perishable does not change that — the client code looks almost identical:
const ai = new client.PerishableOpenAI({
proxyUrl: 'https://your-proxy.example.com'
});
const res = await ai.createChatCompletion({
model: 'gpt-4',
messages: [{ role: 'user', content: 'Hello!' }]
});
The differences live in the network. The Authorization header on the
wire is Bearer eyJhbGc… (a short-lived JWT), not your real key. The
proxy attaches the real key on the server side. Your user-facing
latency is the proxy hop, which, if you co-locate the proxy with the
upstream provider, is on the order of milliseconds.
What the four screenshots have in common
None of them required the attacker to have prior context about your company, your team, or your app. They required: a copy of the binary, or a working proxy on a phone, or GitHub code search. All public, all free, all reproducible by any competent intern.
The mitigation is not to make the client smarter. The mitigation is to make the secret transient. Then the attacker’s screenshots are of a token that has already gone off.
What this looks like in practice
Concretely, the rotation goes from “human in a Slack channel at 3am” to “the system, every fifteen minutes, silently”. The threat model flips. You no longer need to detect a key leak. You assume every session token has leaked and design for that. The leak becomes a nothing-event because the cost of a leaked token is bounded by its TTL and its rate limit.
That changes the team’s relationship with the problem. Key rotation stops being a weekly fire and becomes a property of the system. Engineers stop having to remember to scrub stack traces. Support stops getting screenshots from worried users about their billing. The whole category of incident — “the key is on Reddit” — disappears. Not because the key cannot leak, but because the leakable thing is no longer the long-lived secret.
What you actually have to do
If you take one thing from this, take this: separate the long-lived secret from the thing you hand to the client. Whatever the mechanism — Perishable, a hand-rolled JWT issuer, a vendor’s gateway — the shape is the same. Server holds the key. Client holds a token. Token expires. Token is bound to something that makes it non-portable.
The implementation can be ten lines or ten thousand. The shape is the point. The four screenshots only work because the long-lived secret and the client-facing credential are the same string. Once they are not, the photo album of leaked keys becomes irrelevant.
The yoghurt-pot model: use the date sticker. Trust the sticker. Throw the carton out when the sticker says to. Nobody is sad about last week’s yoghurt.
Filed under: client-side, threat-model, mobile. Spotted a mistake? Open an issue.