Where to Put Your OpenAI API Key in a Flutter App: Not in the App
Every trick for hiding a key in a binary loses to unzip and strings. The one route handler that fixes it, streaming included, and what to do if you already shipped one.
There is a question that gets asked in every Flutter community about once a week: what is the best way to hide an OpenAI API key inside the app? Environment variables, an obfuscated build, a string split into four pieces and reassembled at runtime, a native library, encryption with a key that is also in the app.
All of them lose to the same two commands, and they lose for a reason no amount of cleverness gets around. Your app has to send that key in plaintext to reach the API, so your app has to be able to produce it in plaintext, so anyone holding your app can too. The question is not how to hide it. The question is how to stop shipping it.
Three minutes with unzip
An APK is a zip file. An IPA is a zip file. Nothing about either is hostile territory for someone with a laptop:
$ unzip -q app-release.apk -d out/
$ strings out/lib/arm64-v8a/libapp.so | grep -E 'sk-[A-Za-z0-9_-]{20,}'
sk-proj-J8f2QK...That is the whole attack. It needs no jailbreak, no rooted device, no reverse engineering skill — just a copy of your app, which is what a store listing is for. And it finds keys stored every way people usually store them:
--dart-define and String.fromEnvironment feel safe because the value never appears in your source tree. It appears in the compiled snapshot instead, as a constant, in libapp.so. The grep above is aimed directly at it.
flutter_dotenv is worse, not better. The .env file is declared in pubspec.yaml as an asset, which means it is copied into the bundle verbatim. It is not compiled into anything. It is a text file inside your app, at assets/.env, readable with cat.
flutter build --obfuscate renames classes, methods and fields. It does not encrypt string literals, because the program needs them intact to run. Your key comes through untouched; only the code around it is harder to read.
Splitting the key across several constants and joining them at runtime defeats a naive grep, which is why it feels like it works. It does not survive anyone who runs the app under a proxy and reads the Authorization header off the wire, which takes about as long as the unzip did.
What it actually costs
Leaked keys are not found by a curious user reading your bundle for fun. They are found by scrapers that pull APKs from mirror sites in bulk and pattern-match them, because a working key with a payment method behind it has resale value. The gap between publishing and abuse is measured in days.
Two consequences, and the second one is the one people forget. The usage is billed to you, and API spend is not refundable the way a fraudulent card charge is — it was a valid key making valid requests. And rate limits are per-organisation. Somebody else running your key flat out does not just cost money; it means your paying customers get 429s while it happens, and your app looks broken for a reason no crash report will ever explain.
The fix is a server you already have
The key belongs somewhere you control at runtime, which means one hop between the app and the model:
before app ──(sk-… in the header)──> api.openai.com
^ key ships to every device
after app ──(user's ID token)──> your API ──(sk-…)──> api.openai.com
^ key exists in one place you ownThat middle box is not a project. It is one route handler, and if your template already ships a Next.js admin panel, you have somewhere to put it today. A Firebase Cloud Function or a Supabase Edge Function is the same shape if that is what you are running.
Here is the version that streams, because a chat UI that waits eight seconds and then paints a wall of text feels broken even when it is working:
export async function POST(request: Request) {
// 1. Who is calling? An unauthenticated proxy is just your
// leaked key with extra steps.
const uid = await verifyCaller(request);
if (!uid) return new Response("Unauthorized", { status: 401 });
// 2. Has this user had their share today?
if (!(await consumeQuota(uid))) {
return Response.json({ error: "daily_limit" }, { status: 429 });
}
// 3. Never forward the client's body as-is. The model, the
// system prompt and the ceiling are server decisions.
const { messages } = await request.json();
const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o-mini",
max_tokens: 800,
stream: true,
messages: [SYSTEM_PROMPT, ...trimToLastTurns(messages, 10)],
}),
});
// 4. Hand the SSE body straight through — no buffering, so the
// first token reaches the phone as soon as it exists.
return new Response(upstream.body, {
headers: { "Content-Type": "text/event-stream" },
});
}Step three is the one that gets skipped, and skipping it turns the proxy back into the problem. If the handler forwards whatever JSON the client sent, a caller sets model to your most expensive option and max_tokens to its ceiling, and you have built an open relay to your own account. The client sends conversation turns. Everything that costs money is decided on the server.
Trimming the history matters for the same reason. Chat requests are billed on the whole context every time, so a conversation that keeps appending is one whose cost per message climbs all afternoon. Ten turns is a reasonable default; summarise older ones if the product needs long memory.
Proving the caller is your app
A public endpoint with no check is a leaked key with a nicer URL, so verifyCaller is doing real work. If the app already has Firebase Auth, the app sends its ID token and the server verifies it:
async function verifyCaller(request: Request) {
const token = request.headers.get("authorization")?.slice(7);
if (!token) return null;
try {
// Verified against Google's public keys — a client cannot mint one.
const decoded = await getAuth().verifyIdToken(token);
return decoded.uid;
} catch {
return null;
}
}For an app with no sign-in — and plenty of AI templates let people try before registering — Firebase App Check is the equivalent. It attests that the request came from a genuine build of your app on a real device, using Play Integrity on Android and DeviceCheck on iOS, and it is the difference between an anonymous endpoint and an anonymous endpoint that only your app can call.
Neither is unbreakable, and that is fine. The goal is not a perfect wall. It is that abuse now costs an attacker real effort per user and is revocable — you can rate-limit one account, ban it, or turn the endpoint off — where a leaked key is unlimited, anonymous and permanent until you rotate it.
The Flutter side
The app loses its key and gains a base URL. Reading an SSE stream is the only part that is not a plain POST, and it is about fifteen lines:
Stream<String> send(List<Message> messages) async* {
final token = await FirebaseAuth.instance.currentUser!.getIdToken();
final request = http.Request('POST', Uri.parse('$baseUrl/api/chat'))
..headers.addAll({
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
})
..body = jsonEncode({'messages': messages.map((m) => m.toJson()).toList()});
final response = await http.Client().send(request);
if (response.statusCode == 429) throw QuotaExceeded();
await for (final line in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
// SSE frames arrive as "data: {json}", terminated by "data: [DONE]".
if (!line.startsWith('data: ')) continue;
final payload = line.substring(6);
if (payload == '[DONE]') return;
final delta = jsonDecode(payload)['choices'][0]['delta']['content'];
if (delta != null) yield delta as String;
}
}Two details worth keeping. Use LineSplitter rather than splitting the raw chunks yourself: a network chunk boundary lands in the middle of a JSON object often enough that hand-rolled parsing works in testing and fails on a bad connection. And give the 429 its own type, because the user hitting a daily limit is a product state that deserves a real message, not the generic error banner.
Cap the spend before somebody else does
The proxy moves the key out of reach. It does not, by itself, stop one enthusiastic user from costing you a hundred dollars, so put three limits in place while you are here.
A per-user daily quota — that is consumeQuota above, and it can be one counter row keyed by user and date. Set it at a number a real user would never reach and a script reaches in a minute.
A hard budget on the API project, set in the OpenAI dashboard, so there is a ceiling that holds even if the quota logic has a bug. Free tiers and forgotten test scripts have both found bugs like that.
The smallest model that does the job. Most template features — captions, summaries, tidying user text — do not need your largest model, and the price gap between tiers is roughly an order of magnitude. Pick per feature rather than per app.
If you already shipped a key
Rotate it now, before the refactor. Not after the proxy is built, not in the next release — the key in the store today is compromised for as long as that build exists, and shipping an update does not remove it from the phones that already have it, or from the APK mirrors that archived it.
The sequence that keeps the app working: stand up the proxy with a new key, release the version that calls it, then revoke the old key once enough users have updated. If your usage graph shows traffic you cannot account for, skip the waiting and revoke immediately — a broken feature for stragglers is cheaper than an open account.
Then set the rule that prevents the next one: no third-party secret ever enters the Flutter project. Not the AI provider, not the payment gateway, not the SMS service. If a credential can spend money, it lives on a server, and the app gets an endpoint instead.
Prep AI — $19
A complete AI app in Flutter with the model calls already behind a backend you own — streamed responses, per-user quotas and not one key in the binary.