Field Notes · Trust & Safety

App Check Doesn't Have a Dashboard

Firebase protects your callable functions, but the readiness signal you actually need lives in Cloud Logging rather than the App Check console. Here's the query we run, how to register the debug tokens most teams overlook, and the simulator quirk that makes your own dev traffic look like an attack.

Nervous EnergyJune 5, 20262 min story · 7 with the deep dive

This is the first of five posts on how we keep abuse off Diffusitron without making the experience worse for everyone else. We defend it in layers: recognizing a returning identity at signup so one person can't keep claiming the free , throttling an account that's using a legitimate grant in ways it wasn't meant for, and scoring how much trust an account has earned over time. Later posts in the series get into those. All of them take one thing for granted underneath: that a request really came from our app, and not from a script replaying someone's credentials against our API. is what defends that assumption, so it's where the series starts.

Defense in depth
A hard gate at the base, trust an account earns at the top.
Each layer rolls out the same careful way: observe before you enforce.

The catch is that App Check barely lets you see whether it's working before you commit to turning it on.

The readiness signal you actually need is sitting in , not in any App Check console. That's the honest summary. The Firebase Console's App Check page has a promising entry for Cloud Functions; clicking it redirects you to the documentation, which suggests writing your own logs-based metric. The advice is sound, but the documentation skips the step that trips most teams up. On day one, the metric is misleading, because a large share of the bad numbers are coming from your own machines.

Every in the Firebase SDK has an option. Turning it on is the only thing that causes a request without a valid token to be rejected. Until you flip that switch, the verification step runs and the result gets recorded, but the request goes through either way. That gap, between verifying and enforcing, is exactly where you want to live for a couple of weeks before you commit. It's the only way to find out ahead of time whether flipping the switch is going to break your app for real users.

We ran a 24-hour readiness check and found that the highest INVALID rates clustered tightly on functions we knew were dev-heavy: claimStarterGrant and claimVerificationBonus. Those callables get hit constantly during onboarding tests, and the team hadn't registered any yet. Functions that real users hit far more than developers do came back at exactly 100% VALID. The pattern read less like an attack and more like a list of which functions our own developers exercise most. We had been reading a confounded signal and nearly acted on it.

Here's what App Check does in a debug build: the SDK generates a UUID the first time the app starts, prints it to the Xcode console, and uses that UUID as the token for every subsequent call. The server has no way to verify that token unless you explicitly register it in the Firebase Console. Until you do, every simulator hit shows up in your logs as INVALID. Your entire dev team adds to that count every time they exercise a callable during a normal cycle, and unless you clean it up on purpose, your own traffic is indistinguishable from a determined attacker.

Two caveats matter before you act on anything you see. App Check is a high bar rather than a guarantee. A determined attacker with a real device can proxy tokens or extract attestation material; that holds for every mobile attestation system we know of. What it buys you is a steep barrier against casual abuse, the kind that distorts your numbers in the first place. The second caveat is that App Check occasionally turns away legitimate users through no fault of their own. A device whose clock is off by more than about ten minutes fails validation. A device just restored from a backup can carry a key that considers invalidated, and the app has to walk through a fresh attestation handshake before the next callable succeeds. Any layer you build on App Check has to assume it will sometimes reject a real person.

Everything here is about reading App Check in aggregate, to decide whether enforcement is safe. Later in the series we go the other way and read the same attestation signal one account at a time, turning it into a per-user trust score we can spend on the accounts that have earned it. This post is the foundation that one builds on.

Observe before you enforce, and make sure the observation is one you can actually see.

The deep dive
That's the story. Below is how we actually built it, in detail, for anyone who wants to implement something similar. If that's not you, this is a clean place to stop.

Where the readiness signal actually lives

Every callable invocation, while App Check is configured, writes a structured log entry recording the result of the verification step. Open the Logs Explorer for your project and paste in this filter:

logs explorer filter
resource.type="cloud_function"
labels."firebase-log-type"="callable-request-verification"

That returns one entry per callable invocation. The interesting field on each entry is jsonPayload.verifications.app, which takes one of three values:

  • VALID. The client sent a token, and the server verified it. This is the green path.
  • INVALID. The client sent a token, and the server rejected it. Malformed, expired, signed by an unrecognized key, or otherwise unconvincing.
  • MISSING. The client didn't send a token at all. Older app versions that pre-date your App Check rollout, or clients bypassing your SDK entirely.

MISSING and INVALID point at different problems. MISSING means you have older installs that don't know about App Check yet, and you need to wait for those to age out before enforcement is safe. INVALID means tokens are being generated but rejected, which is a different problem entirely.

A query you can actually live with

The Logs Explorer filter is fine for spot-checking, but you want a per-function rollup with a percentage over a window long enough to see weekly patterns. Cloud Logging's lets you run SQL against your log buckets. You'll need to upgrade the _Default bucket in your project to support analytics first, from the Logs Storage page in the Cloud Console. Once that's done:

log analytics query
SELECT
  resource.labels.function_name AS fn,
  JSON_VALUE(json_payload.verifications.app) AS app_check,
  COUNT(*) AS n
FROM `YOUR-PROJECT.global._Default._AllLogs`
WHERE labels['firebase-log-type'] = 'callable-request-verification'
  AND timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY fn, app_check
ORDER BY fn, n DESC;

The output is one row per (function, verification result) pair, which you can pivot into something like this:

sample output
function                       VALID  INVALID   %VALID
claimVerificationBonus            25       85    22.7%
claimStarterGrant                 63       42    60.0%
onClientCheckIn                    4        4    50.0%
estimateCost                     133        0   100.0%
submitImageGenJob                119        0   100.0%
updateWorkflowBlurHash           143        0   100.0%
deleteWorkflow                    77        0   100.0%

The first time you run this against a real project, the numbers will look alarming. We had a function come back at 22.7% VALID. If we had turned enforcement on at that moment, we would have broken the verification-bonus claim flow for roughly three out of four users. We didn't, because the numbers weren't telling the truth yet.

Screenshot placeholder

Log Analytics query result

Replace with a screenshot of the SQL query result in the Cloud Console.

The simulator gotcha

When you initialize App Check on the client, you tell it which provider to use for each platform. In a release build, iOS uses App Attest, which talks to a real hardware attestation primitive. In a debug build, it switches to the 'debug' provider:

lib/firebase.ts
import { getApp } from '@react-native-firebase/app';
import {
  initializeAppCheck,
  ReactNativeFirebaseAppCheckProvider,
} from '@react-native-firebase/app-check';

const app = getApp();
const provider = new ReactNativeFirebaseAppCheckProvider();

provider.configure({
  android: { provider: __DEV__ ? 'debug' : 'playIntegrity' },
  apple:   { provider: __DEV__ ? 'debug' : 'appAttestWithDeviceCheckFallback' },
  isTokenAutoRefreshEnabled: true,
});

initializeAppCheck(app, {
  provider,
  isTokenAutoRefreshEnabled: true,
});

The debug provider generates a UUID the first time the app starts, prints it to the Xcode console, and uses it as the token for every subsequent call. The server rejects it until you register that UUID in the Firebase Console, so every simulator hit shows up as INVALID. Your whole team adds to that count on every dev cycle, and dev traffic against a real Firebase project looks the same as real abuse.

To App Check, your dev traffic and a determined attacker look alike. The one difference is that you can register your own tokens.

Registering a debug token, properly

The procedure is short. What makes it tedious is that you have to repeat it once per developer, per simulator install, per Firebase project.

iOS Simulator

Run the app once in a debug build with Xcode attached and trigger any callable. In the Xcode console, filter for Debug Token. The first time the App Check SDK mints a token, it prints:

xcode console
[Firebase/AppCheck][I-FAA001001] Firebase App Check Debug Token:
12345678-90AB-CDEF-1234-567890ABCDEF

Copy that UUID, then open the Firebase Console, navigate to App Check, find the iOS app entry, click the three-dot menu, and choose Manage debug tokens. Add the UUID with a name like Richard sim, iPhone 15 Pro. That label will save you real time six months from now when you're staring at a list of entries named token 4. Save, re-run the app, rerun your callable, and the next log entry should come back as app: VALID.

The SDK regenerates the UUID every time the simulator's app data gets wiped. Pin it with an environment variable on the run scheme so it survives wipes:

  1. Xcode → Product → Scheme → Edit Scheme → Run → Arguments → Environment Variables.
  2. Add FIRAAppCheckDebugToken with your UUID as the value.
  3. Save. Every subsequent run uses the same token, and your console registration survives simulator wipes.

When we add Android

We don't ship Android yet. The steps are the same when we get there: the debug provider prints a secret to logcat (look for Enter this debug secret into the allow list), and you register it under the Android app's own debug-token entry, separate from the iOS list.

Production project versus development project

Debug token registration is per-project. Running your dev simulator against the production project means you also need to register in the production console. Otherwise your local prod-pointing builds keep showing up as INVALID in the very metrics you're trying to clean up. We use the same UUID in both projects, just registered twice with the same name. Register every device in your QA loop too, including TestFlight builds on real hardware you want to exempt from production metrics during a test pass.

What the metric looks like once it's honest

After registering the team's debug tokens, the numbers settle into something readable. Percentages on the dev-heavy functions creep upward as old uncategorized traffic ages out of the rolling window. Functions real users hit more often than developers stay near 100%. The residual INVALID and MISSING counts that don't go away after a week or two are now actually meaningful.

The remaining INVALIDs, in our experience, fall into a short list:

  • Devices with significant . App Attest tokens are time-bounded, so a phone whose clock is more than ten minutes off fails on every call. Nothing you can do server-side, but support tickets that read like "the app doesn't work for me" sometimes turn out to be this.
  • Devices recently restored from a backup. The first call after a restore tends to fail as the Secure Enclave re-keys; subsequent calls recover on their own.
  • Real abuse. Callables that hand out money, credits, or capacity are economically attractive to attack. We haven't actually caught anyone doing this. Every persistent INVALID we've chased resolved into one of the cases above. We keep watching anyway.
  • Outage windows. Apple's attestation infrastructure occasionally has a rough afternoon. These show up as a sharp simultaneous spike across all functions, which is what tells them apart from the steadier rhythm of an actual attack.

The MISSING bucket should approach zero before you enforce. If it isn't, you have older installs in the field that would get a hard stop the moment you flip the switch. Wait for them to age out, ship a forced update, or budget for the support cost.

The readiness rule we use

We wait to enforce until every callable, individually, looks good for an entire week. A healthy average alone isn't enough, because one straggler function sitting at 80% VALID can lock you out of a critical flow the instant you enforce. The only way to find that straggler is to read the per-function breakdown.

The average is reassuring, but the minimum across your functions is what the decision actually rides on.

We also stage enforcement: callable functions that spend an existing balance enforce first, because they're the lowest-collateral case if something goes wrong. The bonus and signup callables enforce later, after we've watched the lower-stakes endpoints under enforcement. Session-bootstrap functions like onClientCheckIn go last, because their failure would silently degrade the entire app experience for affected users.

Waiting a week, watching the per-function minimum, and saving the riskiest callables for last are all the same move. It's the discipline behind every control in this series, and we wrote it up on its own in observe before you enforce.

What we'd take with us

  • The readiness signal exists; it lives in your structured callable logs rather than a console dashboard. Write the rollup once and keep it as a permanent fixture.
  • Register your debug tokens before you draw any conclusions from the metric. Anything you decide before that is downstream of a confounded signal.
  • Pin debug tokens via FIRAAppCheckDebugToken in your iOS scheme so simulator wipes don't silently invalidate your registrations.
  • Register per-project as well as per-device. If you ever run a dev build against your production project, the production console needs to know about that token too.
  • Read the per-function breakdown rather than the average. The functions that handle real money are exactly the ones a healthy aggregate hides most easily.
  • Stage enforcement so the highest- callables are last. Being wrong on onClientCheckIn costs far more than being wrong on updateWorkflowBlurHash.