Demo, all content is generated
Question

Someone used my OpenAI key from my Expo app and I got a $340 bill

Solved · 5204 views · asked by emeka · edited

I have a small app on the Play Store (about 200 users). The AI feature calls OpenAI directly from the app. The key was in .env as EXPO_PUBLIC_OPENAI_KEY, which I thought was safe because .env is in .gitignore.

Yesterday: $340 of usage, mostly on a model I don't even use. I revoked the key. What happened, and how do I build this properly? I can't afford this again.

What I’ve tried

Revoked the key and turned off the AI feature for now. Set a monthly budget limit in OpenAI. Asked Lovable how to hide it, it suggested obfuscating the string, which I don't trust.

Comment
Was the model it was used for something like gpt-4o or o-series? Abusers usually pick the most expensive one. lena_ops · edited
yes the expensive one, how did you know emeka · edited
Check the OpenAI usage page for other projects/keys too. If one key leaked, check where else you pasted it. chidi_eze · edited
This happens so often. Glad you caught it at 340. nadia_r · edited
Going to check my own app right now... yara_h · edited

3 answers

Marked as helpful by the asker
lena_ops · edited

Sorry, this is a painful way to learn it. What happened: anything with the EXPO_PUBLIC_ prefix is inlined into the JavaScript bundle that ships in your APK. Anyone can unzip the app and search for sk-. .gitignore only keeps it out of git, not out of the app.

Rule: a secret can never live in the app. Not in env, not obfuscated, not split in pieces.

The proper setup:

  1. App calls your backend (a Supabase Edge Function, a Vercel function, a small server).
  2. The backend checks who's calling (user must be logged in, verify their JWT).
  3. The backend holds the OpenAI key and makes the call.
  4. The backend enforces limits: per user per day, max tokens, allowed model only.

Point 4 is what saves you next time. Even if someone abuses a logged-in account, they're capped.

Also: set a hard monthly limit on the OpenAI project, and ask OpenAI support about the charges, explaining the key was leaked from a client app. No guarantees, but worth asking.

Comment
Thank you for not making me feel stupid. Moving it to a Supabase edge function today. emeka · edited
For the edge function: supabase.auth.getUser() with the Authorization header from the request, reject if no user. Then a simple usage table with a daily count per user id. jb_supa · edited
Also bump your app version and push an update without the old code path, old installs will keep trying the dead key otherwise. ben_mobile · edited
Update: edge function + per user daily limit of 30 requests is live. OpenAI support refunded part of it too. emeka · edited
ben_mobile · edited

Mobile-specific addition: also turn on App Check / Play Integrity (or App Attest on iOS) for the backend endpoint later. It makes it harder for scripts to call your backend pretending to be your app. It's not a replacement for login + limits though, just an extra layer.

Comment
Adding this after the backend is done emeka · edited
femi_o · edited

Check your other EXPO_PUBLIC_ variables too. Supabase anon key there is fine (it's meant to be public, RLS protects you), but any service role key or Stripe secret key is the same problem.

Comment
only the anon key, phew emeka · edited