If you put an OpenAI key in a Flutter app, it’s public. Obfuscating it, splitting the string, keeping it in a bundled .env file, fetching it from Remote Config on first launch: every one of these ends with the key in memory on a device somebody else controls, and getting it out is a matter of patience. This post shows how to use the OpenAI API from Flutter without exposing the API key, and the same design applies to React Native, Swift, Kotlin or a browser app. The client never talks to OpenAI. Your server does.

Why the key can’t live in the app

OpenAI’s production guidance says not to expose keys in your code and to keep them in environment variables or a secret manager. A shipped app binary is code you’ve handed to every user. Once a key leaks it isn’t only a billing problem: whoever has it can burn through your project’s rate limits, and your logs fill with traffic you can’t tie to any user.

There’s a quieter problem too. If the client builds the request, the client chooses the model, the prompt and the token limit, and anyone with an intercepting proxy can change all three. So even a perfectly hidden key would still let users run any prompt they like on your account.

Using the OpenAI API from Flutter without exposing the API key

The shape is simple:

  1. The app signs the user in (Firebase Auth, your own JWTs, whatever you already use).
  2. The app calls your endpoint with the user’s token and only the user’s input: meal text, a question, an image reference.
  3. The server verifies the token, checks the user’s quota, builds the prompt, picks the model, and calls OpenAI with a key held in a secret store.
  4. The server validates and trims the response before sending it back.

On Firebase, a callable Cloud Function handles the auth plumbing. Here’s a minimal 2nd-gen version using Firebase’s documented defineSecret, enforceAppCheck and request.auth, plus the official OpenAI Node SDK:

const { onCall, HttpsError } = require('firebase-functions/v2/https');const { defineSecret } = require('firebase-functions/params');const { initializeApp } = require('firebase-admin/app');const { getFirestore } = require('firebase-admin/firestore');const OpenAI = require('openai');initializeApp();const openaiKey = defineSecret('OPENAI_API_KEY');const MODEL = 'your-pinned-model'; // chosen by the server, never the clientconst DAILY_LIMIT = 20;            // example valueexports.logMeal = onCall(  { secrets: [openaiKey], enforceAppCheck: true },  async (request) => {    if (!request.auth) {      throw new HttpsError('unauthenticated', 'Sign in first.');    }    const text = String(request.data?.text ?? '').trim().slice(0, 500);    if (!text) throw new HttpsError('invalid-argument', 'Empty input.');    // Count before calling the model, inside a transaction.    const db = getFirestore();    const day = new Date().toISOString().slice(0, 10);    const ref = db.doc(`quotas/${request.auth.uid}_${day}`);    await db.runTransaction(async (tx) => {      const snap = await tx.get(ref);      const used = snap.exists ? snap.get('count') : 0;      if (used >= DAILY_LIMIT) {        throw new HttpsError('resource-exhausted', 'Daily limit reached.');      }      tx.set(ref, { count: used + 1 }, { merge: true });    });    const client = new OpenAI({ apiKey: openaiKey.value() });    const response = await client.responses.create({      model: MODEL,      instructions: 'List each food and its portion as JSON.',      input: text,      max_output_tokens: 400,    });    return { result: response.output_text };  });

The Flutter side is one call with the cloud_functions package:

final result = await FirebaseFunctions.instance    .httpsCallable('logMeal')    .call({'text': 'two rotis and a katori of dal'});

Nothing in the app knows which model runs or what the prompt says. Store the key with firebase functions:secrets:set OPENAI_API_KEY and it never touches your repo.

Per-user quotas and auth checks that hold up

The proxy gets the key off the device. Quotas stop the proxy itself from becoming the leak. The rules we follow:

  • Count before you call. Increment the user’s counter in a transaction before the model call. Count afterwards and a burst of parallel requests all pass the check at once.
  • Cap input and output. Truncate or reject long input on the server and set a maximum output token count. You pay per token, so an uncapped text field is an uncapped bill.
  • Own the model name. Keep it in server config. Switching models becomes a deploy instead of an app release, and nobody can ask for the expensive one.
  • Verify tokens properly. If you run your own JWT auth instead of Firebase’s, verify against an explicit algorithm and a key you chose. Accepting alg: none, or letting the token decide which key verifies it, are the classic forgery holes.
  • Add attestation. App Check (or Play Integrity and App Attest directly) makes it harder for a script to pose as your app. It sits in front of auth and quotas. It doesn’t replace them.
  • Send a user identifier. OpenAI recommends a safety identifier for each user, hashed so no personal data leaves your server. If someone abuses your app, OpenAI can see it’s one user and not your whole project.
  • Set project limits. OpenAI supports rate and spend limits per project. They’re the outer fence, not the plan.

Crumb Count, a Flutter nutrition app we shipped, does AI meal logging this way: a server-side proxy, per-user quotas and a model choice the server owns, backed by a 1,299-line adversarial test suite that throws JWT forgery, key confusion and cost attacks at the endpoint. Writing the attacks as tests is the cheapest way we’ve found to keep them fixed.

Not on Firebase?

None of this depends on Cloud Functions. A Node or Laravel endpoint, a serverless function on any cloud or a small container does the same job. Keep the key in the platform’s secret store, put auth and quota checks before the model call, and log usage per user so abuse shows up quickly. Web apps follow the same rule: anything in your JavaScript bundle is public, and that includes NEXT_PUBLIC_ variables in Next.js, so the OpenAI call belongs in a route handler or server action.

Streaming works through a proxy as well. You stream from OpenAI to your server and from your server to the client. It’s more plumbing, and the key still stays where it belongs.

If you’re adding a chat or AI feature to an app and want someone to check the proxy before it ships, orithLabs builds these for clients and tests the abuse cases as part of the job. Our AI chatbots page has more on how we work.