As an indie developer who has shipped iOS apps since 2014, I once lost a full day to a frustrating symptom: Firebase AI Logic worked fine in the simulator, but the moment I pushed the build to TestFlight, every single Gemini call failed with a 403.
The error message is short — usually just PERMISSION_DENIED or The request is missing a valid API key — which makes it hard to tell whether the problem is in your code or your configuration. This article walks through the three causes I actually hit, and the order in which to check them.
First, read the full 403 response
When you call Gemini through Firebase AI Logic (the FirebaseAI module in Swift), a failure comes back like this:
import FirebaseAI
let model = FirebaseAI.firebaseAI(backend: .googleAI())
.generativeModel(modelName: "gemini-2.5-flash")
do {
let response = try await model.generateContent("Hello")
print(response.text ?? "")
} catch {
// Don't swallow this — print the error as-is
print("Gemini call failed:", error)
}Printing error directly in the catch block reveals details alongside httpStatusCode = 403, such as status: PERMISSION_DENIED or App Check token is invalid. Reading that full message is the starting point for diagnosis. If you only notice that response.text is nil and move on, you'll take the long way around.
Behind a 403 there are broadly three causes. Checking them top to bottom is the fastest path.
Cause 1: App Check is enforced but no token is being issued (most common)
This was the culprit in my case. Once you flip App Check to "Enforce" in the Firebase console, any request without a valid App Check token is rejected with a 403. The reason it worked in the simulator but failed on device and TestFlight: the simulator was passing a debug-provider token, while on a real device I had never initialized the App Attest provider.
The fix is to set the App Check provider before FirebaseApp.configure(). Get the order wrong and the provider gets swapped in after Firebase initializes, so your first few requests fly out with no token.
import FirebaseCore
import FirebaseAppCheck
class AppCheckSetup: NSObject, AppCheckProviderFactory {
func createProvider(with app: FirebaseApp) -> AppCheckProvider? {
// App Attest on iOS 14+, DeviceCheck on older devices
if #available(iOS 14.0, *) {
return AppAttestProvider(app: app)
}
return DeviceCheckProvider(app: app)
}
}
// At launch, call this BEFORE configure
AppCheck.setAppCheckProviderFactory(AppCheckSetup())
FirebaseApp.configure()To test in the simulator during development, use the debug provider and register the debug token (printed to the console) under "App Check → your app → Manage debug tokens" in the Firebase console.
#if DEBUG
AppCheck.setAppCheckProviderFactory(AppCheckDebugProviderFactory())
#else
AppCheck.setAppCheckProviderFactory(AppCheckSetup())
#endif
FirebaseApp.configure()One caveat: because App Attest verifies device integrity server-side, the first verification can take a few seconds, even on a TestFlight build. If your app calls Gemini immediately on launch, the token may not be issued yet and you'll get a 403. Waiting for a user action before the first call, or adding a light retry, makes it stable.
Cause 2: The required Google APIs are not enabled
If you run the initial setup from "AI Logic" in the Firebase console via the wizard, the required APIs are enabled automatically. But if you reuse another Firebase project, or just swap in a GoogleService-Info.plist from elsewhere, the APIs can stay disabled and you'll get a 403.
The required API depends on the backend you use:
- Gemini Developer API backend (
.googleAI()):firebasevertexai.googleapis.comand the Generative Language API - Vertex AI backend (
.vertexAI()):aiplatform.googleapis.com
Check under "APIs & Services → Enabled APIs" in Google Cloud Console that these appear for the target project. To check or enable from the command line:
# Check whether enabled
gcloud services list --enabled --project=YOUR_PROJECT_ID | grep -E "aiplatform|firebasevertexai"
# Enable if missing (Vertex AI backend example)
gcloud services enable aiplatform.googleapis.com --project=YOUR_PROJECT_IDIn my experience this trap is easiest to fall into when bolting AI features onto an existing app. If the project never went through the new-setup wizard, suspect this first.
Cause 3: The Blaze (pay-as-you-go) plan is not set up
If you use the Vertex AI backend while the project is still on the free Spark plan, you'll hit a 403 or a billing-related error because billing is required. Upgrading to the Blaze plan from "Usage and billing" in the Firebase console resolves it.
The Gemini Developer API backend runs on Spark within the free tier, but for a production app where you want to avoid rate limits, you'll likely move to Blaze anyway. If cost is a concern, set a Google Cloud budget alert for peace of mind.
The isolation order, summarized
When you hit a 403, checking in this order gets you to the cause fastest:
- Print the full error in
catchand look forApp Check token is invalid→ if present, it's Cause 1 - If not, confirm the target API is enabled in Google Cloud Console → if disabled, Cause 2
- If the API is enabled too, check that the billing plan is Blaze → if Spark, Cause 3
- If all of these are satisfied and 403 persists, verify that
GoogleService-Info.plistmatches the calling project (mixing in a plist from another environment makes the console settings and the actual destination diverge)
Causes 1 and 4 in particular can't be spotted by staring at console settings alone. Cross-check the full on-device error log against the PROJECT_ID in the plist your build actually ships.
Preventing a recurrence
My release checklist for AI-enabled apps now includes one line: "Before enforcing App Check, confirm on a real device (TestFlight) that an App Attest token is actually issued." Enforcement is the last switch you flip — turning it on early in development makes isolating any cause far harder.
During development, keep App Check unenforced (monitoring only), watch token issuance in the console, and only switch to enforcement for production. Since adopting that order, I've stopped getting blindsided by 403s.
I hope this saves someone else the day I lost.