It is hidden from the public menu and from search engines, but anyone with the URL can open it. That is acceptable because it contains no real secrets — every key below is a placeholder. For genuine protection, put it behind HTTP Basic Auth or Firebase Authentication at the hosting layer.
Architecture overview
The app never talks to Claude directly. It calls our Firebase Cloud Function, and the function calls the Claude API on the server side. The client only ever sees our endpoint; it has no knowledge of which model answered or where the key lives.
iOS / Android app
│ POST { task, payload } + App Check token
▼
Firebase Cloud Function (aiProxy)
│ • verifies App Check
│ • checks per-user / per-IP rate limit (Firestore)
│ • reads CLAUDE_API_KEY from Secret Manager
▼
Anthropic Claude API (claude-sonnet-4-6)
│
▼ structured JSON → app
The function exposes three task endpoints behind a single deployment:
| Endpoint | Used by | Returns |
|---|---|---|
/analyze | Pantry AI insights | Run-out predictions, optimisation tips |
/recipes | Recipe suggestions | Recipes built from current pantry |
/parse | Quick-add | Free text → structured grocery items |
Why a proxy (and not call Claude from the app)
- The key never ships. An API key embedded in an app binary can be extracted in minutes and abused on your bill. The key lives only in Firebase Secret Manager.
- Abuse control. App Check ensures requests come from our genuine app; rate limiting caps spend per user and per IP.
- Provider independence. Swapping Claude for another model is a server change — no app update, no App Store resubmission (see Swapping providers).
- Stable contract. Prompts and parsing live server-side, so we can iterate without shipping a new build.
Prerequisites
- Node.js 20 LTS and npm installed locally.
- Firebase CLI:
npm install -g firebase-tools, thenfirebase login. - Blaze (pay-as-you-go) plan on the Firebase project — outbound network calls (to Anthropic) require it. Set a budget alert (see Cost & monitoring).
- An Anthropic API key from the Claude Console. Treat it like a password.
- Access to the
smartgrocery-prodFirebase project (or your project ID).
1 · Initialise functions
-
From the repo root, scaffold the functions workspace (skip if
/Cloud/functionsalready exists):terminalfirebase init functions # choose: JavaScript · ESLint yes · install deps yes -
Confirm the runtime is Node 20 in
functions/package.json:functions/package.json{ "engines": { "node": "20" }, "dependencies": { "firebase-admin": "^12.0.0", "firebase-functions": "^6.0.0", "@anthropic-ai/sdk": "^0.30.0" } }
2 · The proxy function
The function reads the key from Secret Manager via defineSecret, verifies the App Check
token, applies a rate-limit check, then forwards the request to Claude. Below is the shape of
functions/index.js — trimmed to the essentials.
const { onRequest } = require("firebase-functions/v2/https");
const { defineSecret } = require("firebase-functions/params");
const admin = require("firebase-admin");
const Anthropic = require("@anthropic-ai/sdk");
admin.initializeApp();
// Key is pulled from Secret Manager at runtime — never in source.
const CLAUDE_API_KEY = defineSecret("CLAUDE_API_KEY");
const MODEL = "claude-sonnet-4-6";
exports.aiProxy = onRequest(
{ secrets: [CLAUDE_API_KEY], region: "us-central1", cors: false, maxInstances: 20 },
async (req, res) => {
if (req.method !== "POST") return res.status(405).end();
// 1 · App Check — reject anything not from our genuine app
const token = req.get("X-Firebase-AppCheck");
try { await admin.appCheck().verifyToken(token); }
catch { return res.status(401).json({ error: "unauthorised" }); }
// 2 · Rate limit (see section 5)
const ip = req.get("x-forwarded-for") || "unknown";
if (!(await allowRequest(ip))) {
return res.status(429).json({ error: "rate_limited" });
}
// 3 · Dispatch to the right task
const { task, payload } = req.body || {};
const client = new Anthropic({ apiKey: CLAUDE_API_KEY.value() });
const prompt = buildPrompt(task, payload); // /analyze | /recipes | /parse
try {
const msg = await client.messages.create({
model: MODEL,
max_tokens: 1024,
system: systemFor(task),
messages: [{ role: "user", content: prompt }]
});
return res.json({ ok: true, data: extractJSON(msg) });
} catch (e) {
console.error("claude_error", e);
return res.status(502).json({ error: "upstream" });
}
}
);
The /parse and /analyze tasks depend on clean structured JSON. Instruct the model to return JSON only in the system prompt, and keep extractJSON() defensive (strip prose, parse, fall back to a local result on failure).
3 · Store the Claude API key as a secret
Never hardcode the key or put it in .env committed to git. Use Firebase Secret Manager:
# Prompts for the value, stores it encrypted in Secret Manager
firebase functions:secrets:set CLAUDE_API_KEY
# Verify (shows metadata, never the value)
firebase functions:secrets:access CLAUDE_API_KEY
If the key ever appears in a log, a screenshot, or a commit, revoke it in the Claude Console and run functions:secrets:set again, then redeploy. Secrets are bound to a function version, so a redeploy is required to pick up a new value.
4 · App Check (abuse protection)
In the Firebase console → App Check, register the iOS app with App Attest (and DeviceCheck as fallback).
In the app, configure App Check at launch so every request carries a token. The function rejects requests whose token fails
verifyToken.Keep a short grace period in "monitor" mode before enforcing, so you can confirm real traffic is passing before you start returning 401s.
5 · Rate limiting
A simple Firestore counter per IP (and/or per user) caps spend and blunts abuse. The free tier already limits AI calls in-app, but the server is the real enforcement point.
async function allowRequest(ip, limit = 60, windowMs = 3600000) {
const ref = admin.firestore().doc(`ratelimits/${ip}`);
return admin.firestore().runTransaction(async (tx) => {
const now = Date.now();
const snap = await tx.get(ref);
const d = snap.exists ? snap.data() : { count: 0, start: now };
if (now - d.start > windowMs) { d.count = 0; d.start = now; }
if (d.count >= limit) return false;
tx.set(ref, { count: d.count + 1, start: d.start });
return true;
});
}
6 · Deploy
# Deploy only this function (faster, safer than deploying everything)
firebase deploy --only functions:aiProxy
# On success the CLI prints the live URL, e.g.:
# https://us-central1-smartgrocery-prod.cloudfunctions.net/aiProxy
First deploy of a secret-bound function will ask permission to grant the runtime access to the secret — accept it. Subsequent deploys are silent.
7 · Test & view logs
# Tail logs live
firebase functions:log --only aiProxy
# Smoke test the parse task (App Check will reject without a token —
# run against the emulator for local checks):
firebase emulators:start --only functions
curl -X POST http://127.0.0.1:5001/PROJECT/us-central1/aiProxy \
-H "Content-Type: application/json" \
-d '{ "task": "parse", "payload": { "text": "2 lb chicken, dozen eggs" } }'
8 · Connect the app
Point the client at the deployed URL. In the iOS app this is a single config value:
static let aiProxyURL = "https://us-central1-smartgrocery-prod.cloudfunctions.net/aiProxy"
No key, no model name, nothing sensitive — just the endpoint. The app sends { task, payload } and an App Check token; everything else is server-side.
Cost & monitoring
- Budget alert: Google Cloud Billing → Budgets & alerts → set a monthly cap with email alerts at 50% / 90% / 100%.
- Cap instances:
maxInstanceson the function bounds worst-case concurrency (and spend). - Watch token usage: log
msg.usagefrom the Claude response to track input/output tokens over time. - Dashboards: Cloud Run metrics show invocations, errors, and latency for the function.
Swapping providers (Claude ↔ DeepSeek)
Because everything routes through this function, switching models is a server change with zero app updates. Gate it behind an env var so you can A/B without redeploying builds:
const PROVIDER = process.env.MODEL_PROVIDER || "claude";
if (PROVIDER === "deepseek") {
// DeepSeek is OpenAI-compatible: POST to /chat/completions
// with { model: "deepseek-chat", messages:[...] }
} else {
// Anthropic SDK (default)
}
DeepSeek is cheaper but not free, processes data in China (a privacy-label consideration for EU/UK), and needs harder testing on the JSON-returning /parse task. Keep Claude as default until usage justifies the switch.
Security checklist
- API key only in Secret Manager — never in source,
.env, or the app. - App Check enforced (not just monitored) before launch.
- Rate limiting active per IP/user.
cors: falseunless a browser client genuinely needs it.- Budget alert configured with email notifications.
- Errors logged without leaking the key or full user content.
- Secret rotated on any suspected exposure, then redeployed.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
401 unauthorised | App Check token missing/invalid | Confirm App Check is configured in the app; check the registered provider |
429 rate_limited | Limit hit for that IP/user | Expected under load; raise limit or window if legitimate |
502 upstream | Claude API error or bad key | Check functions:log; verify the secret value; check Anthropic status |
| Deploy fails on secret access | Runtime lacks Secret Manager grant | Re-run deploy and accept the access prompt |
| Empty/garbled JSON in app | Model returned prose | Tighten the system prompt to JSON-only; harden extractJSON() |
| Outbound call blocked | Project not on Blaze plan | Upgrade to pay-as-you-go; outbound network needs it |
Append ?admin=logout to any URL to clear the admin flag from this browser.