Adding AI features to Flutter apps without leaking API keys
How to add AI features to Flutter apps without leaking API keys: proxy every model call through your backend, authenticate users and rate limit per user.
By Hamza Ghouri
The fastest way to add AI features to a Flutter app is to paste a model API key into the Dart code and call the provider directly. It works in the demo. It is also the fastest way to leak API keys, because anything compiled into a mobile app can be pulled back out of it.
I ship a lot of Flutter apps backed by Node.js and TypeScript, and one AI feature I built recently is an in-app assistant for the Recond Flutter app. Its replies stream in, the UI shows what the agent is doing while it works, and it can propose creating records for the user to confirm. This post is the pattern I'd use for any Flutter AI feature, from a single "summarize this" button to a full assistant.
Why the key cannot live in the app
An APK or IPA is a package you hand to every user. Obfuscation slows people down a little. --dart-define, environment files and "hidden" constants all end up as strings in the binary, and a determined person with a decompiler will find them. Certificate pinning makes traffic harder to inspect but does nothing for a key that is already sitting in the package.
Once a key leaks, anyone can run up your bill, and you cannot rotate it without shipping an app update and waiting for users to install it. Old versions keep the old key forever.
So the rule is absolute: the model API key lives on the server, and the app never talks to the model provider.
The proxy pattern
The architecture is short:
- The Flutter app authenticates the user with your backend as usual and holds a session token.
- The app calls your own endpoint, for example
POST /api/assistant, with that token. - The backend checks who the user is, what they are allowed to do, and whether they are within their limits.
- The backend builds the prompt, calls the model API with the server-side key, and returns (or streams) the result.
That one hop gives you a lot more than security:
- Per-user rate limits and quotas, so one user or one bug cannot drain your budget.
- Prompts on the server, so you can improve them without an app release.
- Provider freedom, because swapping models is a backend deploy.
- Logging and cost tracking per user, per feature.
- Data access control, because the backend decides which records go into the prompt. In a multi-tenant product like Recond, this is where you make sure a user's assistant only ever sees their own organisation's data.
A minimal backend endpoint
Here is the core of it in Express, with a per-user limit in Redis. The model call sits behind a small callModel() wrapper so the route does not depend on any one provider:
import express from "express";
import Redis from "ioredis";
const app = express();
const redis = new Redis(process.env.REDIS_URL!);
async function callModel(prompt: string): Promise<string> {
const res = await fetch(process.env.MODEL_API_URL!, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MODEL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt }),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`Model API error ${res.status}`);
return ((await res.json()) as { text: string }).text;
}
async function underLimit(userId: string, perMinute = 10) {
const key = `ai:rate:${userId}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 60);
return count <= perMinute;
}
app.post("/api/assistant", express.json({ limit: "32kb" }), async (req, res) => {
const userId = res.locals.userId; // set by your auth middleware
if (!(await underLimit(userId))) {
return res.status(429).json({ error: "rate_limited" });
}
const question = String(req.body.question ?? "").slice(0, 2000);
const text = await callModel(`Answer the user's question:\n${question}`);
res.json({ text });
});
A fixed one-minute window is the simplest limiter that works. Add a daily quota per user or per plan next to it, because per-minute limits stop bursts and daily limits stop slow drains. My post on keeping LLM API costs under control in Node.js goes further into budgets and model tiering.
The Flutter side
On the client, the AI feature becomes an ordinary authenticated API call, which is exactly what you want. A few things make it feel good:
- Handle 429 as a normal state. Show a friendly "try again in a minute" message.
- Set a client timeout longer than your backend's, so the server gets to report a clean error first.
- Stream long answers. For an assistant, streamed replies make waiting feel shorter. The Recond assistant streams its replies and paces them so they read as typing, which I describe in UX patterns for AI features.
- Show what is happening. A status line such as "looking up the vehicle" while the backend works tells the user the app has not frozen.
- Confirm before writing. When the AI proposes a change, show it as a card the user confirms or cancels. In the Recond app, a cancelled create card stays cancelled, so a later message cannot quietly revive it.
Things that still need care
Moving the key to the server solves the leak. A few related problems remain:
- Your endpoint is now the thing to abuse. Anyone with a valid account can call it outside the app. That is fine, because it is authenticated and rate limited, but keep input size limits and per-user quotas tight.
- Do not build a generic proxy. An endpoint that forwards any prompt to the model is a free LLM for whoever finds it. Each endpoint should do one job with a server-built prompt, using the user's input as data inside it.
- Treat user text as untrusted. Prompt injection applies to mobile input as much as web input, especially once the assistant can read or create records.
- Log carefully. Log usage and errors per user, and keep personal data out of logs unless you have a reason and a retention rule.
The same rules apply to browser extensions, which have the identical "the package is public" problem. I cover that case in shipping an AI assistant as a browser extension.
Where I'd start
If your Flutter app has a key in it today, rotate the key, move the call to a backend endpoint, and ship that before anything else. Then add per-user limits and a daily quota. Once the plumbing is right, the AI feature itself is the easy part. If you'd like help putting this in place, my services page explains how I work.