August 26, 2026
Decompilaron el IPA, reenviaron generateContent, y Gemini lo cobró en mi Blaze
Firebase AI Logic publicó hoy el security checklist. Template-only (403), replay protection, authenticated-users (401) y spend cap. App…

By Kevin Morales
7 min read
Firebase AI Logic publicó hoy el security checklist. Template-only (403), replay protection, authenticated-users (401) y spend cap. App Check Enforced no cierra la factura.
¡Hola amig@! Mi nombre es Kevin Morales y soy desarrollador mobile por más de 7 años. También soy founder de Meniuz. En iOS uso Firebase todos los días — y Firebase AI Logic cuando la app tiene que hablar con Gemini desde el iPhone, sin un backend propio. Hoy no es un "qué es App Check". Es el checklist que Firebase acaba de publicar para quien llama a Gemini desde el teléfono.
El feature era "describir el plato". Cursor lo ve como 20 líneas de Swift. El reviewer también, si tiene prisa. generateContent queda en el IPA. Si el proyecto se armó antes de julio de 2026, o si alguien dejó App Check en Unenforced, Gemini responde igual. La quota es tuya. Eso es Gemini desde el iPhone sin App Check. El 26 de agosto de 2026 Firebase puso nombre a todo lo que ese PR no tocó.
El changelog de hoy (no un rumor)
El release de Firebase del 26 de agosto de 2026 dice una sola cosa de AI Logic:
Firebase AI Logic published a security checklist to help you understand the important and recommended security best practices for using Firebase AI Logic and calling the Gemini API directly from your mobile and web apps.
La URL real no es /docs/ai-logic/security (esa 404). Es esta:
firebase.google.com/docs/ai-logic/security-checklist
El production checklist la apunta como "dedicated Security checklist for Firebase AI Logic". Tres capas, no una:
- App, device, and user verification — App Check + limited-use tokens, restricciones de la API key, authenticated-users mode.
- Prompt protection and request hardening — server prompt templates + template-only mode.
- Monitoring, limits, and billing — AI monitoring, rate limit por usuario, budget alerts y spend caps.
La frase que me importa, copiada de esa página:
protecting your backend infrastructure, quota, and proprietary prompts requires a multi-layered defense-in-depth approach.
Quota. Prompts. Backend. Si el PR solo prende App Check en la consola y deja el prompt en el cliente, te falta el checklist entero.
Lo que el IPA no puede ser: un Gemini de pago abierto
Firebase AI Logic es un proxy gateway. Las requests del SDK van primero a firebasevertexai.googleapis.com. Ahí se verifica App Check antes de llegar a Gemini Developer API o a Agent Platform Gemini API (antes Vertex AI). La API key de Firebase identifica el proyecto; no autoriza Gemini. El proxy es el que mantiene la Gemini API key en el servidor, no en el binario. No metas una Gemini API key en el codebase. Y no agregues Generative Language API (generativelanguage.googleapis.com) al allowlist de tu Firebase API key: las FAQ dicen que eso expone Gemini a abuse.
Eso cubre la llave. No cubre el prompt. El checklist es explícito: si el system prompt, el schema y el model name viven en el cliente, se extraen con decompilación del binario. Quien tenga un IPA no necesita tu prompt "secreto": lo saca y manda generateContent arbitrario contra tu proyecto. Cada request tiene costo. Eso es denial-of-wallet.
Desde inicios de julio de 2026 (release del 7 de julio), el guided setup de la consola enforcea App Check para AI Logic y el debug provider pasa a ser parte del get-started. Si el proyecto es anterior, puedes tener AI Logic "funcionando" con App Check en Unenforced. Las FAQ documentan dos 403: To access this model, you must enforce Firebase App Check y Firebase AI Logic has been deactivated in this project. Un modelo abusado o un proyecto inactivo sin App Check no es "el modelo está caído". Es Firebase cortándote el grifo.
App Check Enforced tampoco es el final. El 2 de julio de 2026 abrió replay protection opcional: tokens de un solo uso (limited-use). Sin useLimitedUseAppCheckTokens: true en el init, no puedes enforcear replay — los clientes viejos mandan tokens reutilizables y la métrica Unverified: Reused token se llena. Authenticated-users mode (proyecto entero) bloquea requests sin credencial de Firebase Authentication con 401: unauthenticated. Anonymous y email/password cuentan como autenticados — las docs de auth-mode advierten que no son robustos para un recurso de pago. Template-only mode bloquea cualquier request que no use una server prompt template con 403: unauthorized. El spend cap, en Blaze, es lo que pausa el provider de Gemini cuando se pasa el presupuesto. Un budget alert solo manda un correo.
En Meniuz, desde Quito, un clasificador de platos no puede ser un generateContent("Eres un sommelier…") compilado en el IPA. El prompt se va al servidor. El cliente manda un template ID y el nombre del plato, sanitizado.
Implementación
Piso oficial de Get started: Xcode 26.2+, iOS 15+, SPM https://github.com/firebase/firebase-ios-sdk, librerías FirebaseAILogic y FirebaseAppCheck. El módulo se llama FirebaseAILogic desde Apple SDK 12.5.0 (FirebaseAI sigue compilando). Modelo stable en prod: gemini-3.7-flash. Nada de preview, experimental ni alias -latest — eso lo dice el production checklist.
1. App Check en el cliente (antes de FirebaseApp.configure())
Producción, Apple: App Attest (iOS 14+) con fallback a DeviceCheck. Patrón oficial de App Attest. La factory se setea antes de configurar Firebase. Desde julio, con App Check enforceado por el setup, el simulador sin debug provider es un 403.
import SwiftUI
import FirebaseCore
import FirebaseAppCheck
import FirebaseAILogic
final class MeniuzAppCheckProviderFactory: NSObject, AppCheckProviderFactory {
func createProvider(with app: FirebaseApp) -> AppCheckProvider? {
if #available(iOS 14.0, *) {
return AppAttestProvider(app: app)
} else {
return DeviceCheckProvider(app: app)
}
}
}
@main
struct MeniuzApp: App {
init() {
#if DEBUG
AppCheck.setAppCheckProviderFactory(AppCheckDebugProviderFactory())
#else
AppCheck.setAppCheckProviderFactory(MeniuzAppCheckProviderFactory())
#endif
FirebaseApp.configure()
}
var body: some Scene {
WindowGroup { ContentView() }
}
}import SwiftUI
import FirebaseCore
import FirebaseAppCheck
import FirebaseAILogic
final class MeniuzAppCheckProviderFactory: NSObject, AppCheckProviderFactory {
func createProvider(with app: FirebaseApp) -> AppCheckProvider? {
if #available(iOS 14.0, *) {
return AppAttestProvider(app: app)
} else {
return DeviceCheckProvider(app: app)
}
}
}
@main
struct MeniuzApp: App {
init() {
#if DEBUG
AppCheck.setAppCheckProviderFactory(AppCheckDebugProviderFactory())
#else
AppCheck.setAppCheckProviderFactory(MeniuzAppCheckProviderFactory())
#endif
FirebaseApp.configure()
}
var body: some Scene {
WindowGroup { ContentView() }
}
}En el simulador, el debug provider imprime el token en la consola de Xcode:
<Warning> [AppCheckCore][I-GAC004001] App Check debug token: '123a4567-b89c-12d3-e456-789012345678'.<Warning> [AppCheckCore][I-GAC004001] App Check debug token: '123a4567-b89c-12d3-e456-789012345678'.Ese UUID se registra en Firebase console → Security > App Check > Apps → overflow de tu app iOS → Manage debug tokens. En consola, Security > App Check > APIs: fila Firebase AI Logic → Set up → Baseline protection = Enforced. Replay: primero Unenforced (monitoring only) si todavía hay usuarios en builds sin limited-use tokens; Enforced cuando la métrica Unverified: Reused token ya no es tráfico real.
2. Limited-use tokens (sin esto, replay no se puede enforcear)
Patrón oficial de Prevent Gemini API abuse. El parámetro va en la instanciación de Firebase AI Logic, no en cada request.
import FirebaseAILogic
// Gemini Developer API. Agent Platform (antes Vertex AI):
// backend: .agentPlatform(location: "global")
let ai = FirebaseAI.firebaseAI(
backend: .googleAI(),
useLimitedUseAppCheckTokens: true
)import FirebaseAILogic
// Gemini Developer API. Agent Platform (antes Vertex AI):
// backend: .agentPlatform(location: "global")
let ai = FirebaseAI.firebaseAI(
backend: .googleAI(),
useLimitedUseAppCheckTokens: true
)No mergees replay Enforced el mismo día que subes este flag. Los IPA viejos reutilizan el token. Míralo una semana en la pestaña APIs. Después sí.
3. Server prompt template: el cliente no lleva el system prompt
El checklist: prompts, system instructions y configs en el cloud. El app solo manda template ID + variables. En consola: AI Services > AI Logic > Prompt templates → Create new template. ID único en el proyecto (máx. 63, minúsculas, números, guiones; versiona). Ejemplo oficial de formato Dotprompt:
---
model: 'gemini-3.7-flash'
---
{{role "system"}}
All output must be a clearly structured invoice document.
Use a tabular or clearly delineated list format for line items.
{{role "user"}}
Create an example customer invoice for a customer named {{customerName}}.---
model: 'gemini-3.7-flash'
---
{{role "system"}}
All output must be a clearly structured invoice document.
Use a tabular or clearly delineated list format for line items.
{{role "user"}}
Create an example customer invoice for a customer named {{customerName}}.En Meniuz el cuerpo sería el plato, no una factura. El punto no cambia: el system role no sale en el IPA. Lock el template (candado arriba a la derecha) antes de producción. Después de crear o editar, espera un par de minutos a que se propague.
Swift oficial de Get started with server prompt templates. templateGenerativeModel(), no generativeModel(modelName:). Multi-turn con templates aún no está soportado en Swift — las docs lo dicen en texto plano. No inventes startChat sobre la plantilla.
import FirebaseAILogic
enum PlatoPrompt {
static let maxChars = 80
static func sanitize(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed.count <= maxChars else { return nil }
return trimmed
}
}
func describirPlato(_ nombreCrudo: String) async throws -> String {
guard let nombre = PlatoPrompt.sanitize(nombreCrudo) else {
throw PlatoPromptError.inputInvalido
}
let ai = FirebaseAI.firebaseAI(
backend: .googleAI(),
useLimitedUseAppCheckTokens: true
)
let model = ai.templateGenerativeModel()
let response = try await model.generateContent(
templateID: "meniuz-plato-copy-v1-0-0",
inputs: [
"dishName": nombre
]
)
return response.text ?? ""
}
enum PlatoPromptError: Error {
case inputInvalido
}import FirebaseAILogic
enum PlatoPrompt {
static let maxChars = 80
static func sanitize(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed.count <= maxChars else { return nil }
return trimmed
}
}
func describirPlato(_ nombreCrudo: String) async throws -> String {
guard let nombre = PlatoPrompt.sanitize(nombreCrudo) else {
throw PlatoPromptError.inputInvalido
}
let ai = FirebaseAI.firebaseAI(
backend: .googleAI(),
useLimitedUseAppCheckTokens: true
)
let model = ai.templateGenerativeModel()
let response = try await model.generateContent(
templateID: "meniuz-plato-copy-v1-0-0",
inputs: [
"dishName": nombre
]
)
return response.text ?? ""
}
enum PlatoPromptError: Error {
case inputInvalido
}El checklist pide validar y sanitizar las variables en el cliente (límites de caracteres, formato) y guardrails en el system del template. Handlebars concatena input de usuario al prompt final. Un dishName de 8 KB no es un plato: es injection.
4. Template-only mode y authenticated-users mode (consola, no un flag del SDK)
Los dos son project-wide. IAM: firebasevertexai.configs.update (Owner, Editor, Firebase Admin, Firebase AI Logic Admin). Consola: AI Services > AI Logic > Settings.
- Enforce authenticated-users mode → Enforced. Request sin credencial de Firebase Authentication →
401: unauthenticated. En Swift, si el usuario ya está signed-in, el SDK manda la credencial solo. No hay un parámetro extra. Anonymous cuenta: no lo uses como "candado de pago". - Enforce template-only mode → Enforced. Request que no usa server prompt template (incluido
generativeModel+generateContentcon el prompt en el cliente) →403: unauthorized.countTokensno se bloquea. Capacidades que las plantillas aún no soportan (en Swift: multi-turn) también se bloquean.
No prendas template-only si TestFlight todavía llama generativeModel. Primero shippea el IPA con templateGenerativeModel. Después el toggle. Si no, el 403 se lo comes tú.
5. API key, 100 RPM, spend cap
En Google Cloud → APIs & Services → Credentials, la Firebase API key:
- Application restrictions: bundle ID de la app iOS.
- API restrictions:
firebasevertexai.googleapis.comsí. El resto, solo los Firebase services que uses. Generative Language API no.
Rate limit por usuario de Firebase AI Logic: default 100 RPM. El quota del provider de Gemini manda si es más bajo. 429 = te pasaste. Se edita en Google Cloud → Firebase AI Logic API → Quotas.
En Blaze: budget alert (correo) y budget spend cap sobre Gemini Developer API o Agent Platform Gemini API. El alert no pausa nada. El cap sí. Avoid surprise bills. AI monitoring se prende en AI Services > AI Logic > Settings; ahí ves request count, latency, tokens, y los 401 / 403 del checklist.
Proyectos separados para dev, test y prod. El debug token del simulador no vive en el proyecto de producción.
Conclusión
Hoy no salió un modelo nuevo. Salió el documento que te dice por qué Gemini desde el iPhone sin App Check — o con App Check y el prompt en el IPA — es un cargo abierto. El security checklist pide cuatro candados que el PR de "solo el prompt" no toca: App Check con limited-use tokens, authenticated-users (401), template-only (403) y un spend cap que sí pausa Gemini. El system prompt no se reviewa en el diff de Swift. Se reviewa en la plantilla, con candado, en la consola. Si el IPA todavía manda el prompt en claro, el checklist no está hecho — aunque App Check diga Enforced.
Recuerda
Aprende cada día y serás una nueva persona
¡Gracias por leer este artículo, estoy muy emocionado de seguir compartiendo mi conocimiento al mundo entero!