Internal documentation · Engineering

Cloud Function Deployment — Firebase & Claude

How to deploy and operate the AI proxy that sits between the app and the Claude API. The function keeps the API key off every device, enforces App Check and rate limits, and exposes three stable endpoints the client depends on.

Owner: Engineering Stack: Firebase Functions v2 · Node 20 Model: claude-sonnet-4-6 Last reviewed: 2026-05
This page is unlisted, not secured.

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.

data flow
  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:

EndpointUsed byReturns
/analyzePantry AI insightsRun-out predictions, optimisation tips
/recipesRecipe suggestionsRecipes built from current pantry
/parseQuick-addFree 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, then firebase 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-prod Firebase project (or your project ID).

1 · Initialise functions

  1. From the repo root, scaffold the functions workspace (skip if /Cloud/functions already exists):

    terminal
    firebase init functions
    # choose: JavaScript · ESLint yes · install deps yes
  2. 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.

functions/index.js
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" });
    }
  }
);
Always force a JSON response.

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:

terminal
# 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
Rotate on exposure.

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)

  1. In the Firebase console → App Check, register the iOS app with App Attest (and DeviceCheck as fallback).

  2. In the app, configure App Check at launch so every request carries a token. The function rejects requests whose token fails verifyToken.

  3. 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.

functions/rateLimit.js
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

terminal
# 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

terminal
# 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:

AppConfig.swift
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: maxInstances on the function bounds worst-case concurrency (and spend).
  • Watch token usage: log msg.usage from 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:

provider switch
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: false unless 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

SymptomLikely causeFix
401 unauthorisedApp Check token missing/invalidConfirm App Check is configured in the app; check the registered provider
429 rate_limitedLimit hit for that IP/userExpected under load; raise limit or window if legitimate
502 upstreamClaude API error or bad keyCheck functions:log; verify the secret value; check Anthropic status
Deploy fails on secret accessRuntime lacks Secret Manager grantRe-run deploy and accept the access prompt
Empty/garbled JSON in appModel returned proseTighten the system prompt to JSON-only; harden extractJSON()
Outbound call blockedProject not on Blaze planUpgrade to pay-as-you-go; outbound network needs it
Sign out of admin on this device

Append ?admin=logout to any URL to clear the admin flag from this browser.