Skip to content

App Check

The Local Emulator Suite does not include an App Check emulator. Fireemu adds a local App Check implementation, so you can test how Authentication, Firestore, Storage and callable functions treat requests with and without a valid App Check token.

What you can test locally

  • The debug-token exchange, which works with no configuration under strict.
  • Enforcement for Authentication, Firestore and Storage (off, unenforced or enforced) and for callable functions with enforceAppCheck, including request.app.
  • The refusal of a missing, malformed, expired or badly signed token.
  • getAppCheck().verifyToken() inside an onRequest function.

Out of scope on purpose

  • Real attestation through Play Integrity, App Attest, DeviceCheck or reCAPTCHA. Local App Check uses debug tokens.
  • Limited-use tokens and replay protection. A callable with consumeAppCheckToken: true is refused with 501 APP_CHECK_REPLAY_UNSUPPORTED, and verifyToken(token, { consume: true }) answers app-check/unimplemented, so a test cannot pass on a token production would have consumed.
  • Tokens that outlive their daemon: they are signed with a key that belongs to one daemon instance (see Callable functions).
  • Python callables have further limits; see Cloud Functions (Python).

Step 1: Quick testing with no configuration

Section titled “Step 1: Quick testing with no configuration”

Under the strict profile the debug-token exchange, the JWKS and the callable trust boundary are served without an appCheck block, and every product baseline is off. Any well-formed debug token (a UUIDv4) exchanges for a token for any app with a standard app ID (1:<project number>:<platform>:<id>); the app is registered on first use. So an enforceAppCheck callable can be tested with a token from the exchange, no app registration and no digest, while a missing or malformed token is still refused. The key pair is generated in the background after start, so the first exchange does not wait for it.

For example, with fireemu up running and no appCheck block, any UUIDv4 exchanges (the host is the one printed as FIREEMU_APP_CHECK_EMULATOR_HOST, which is the Authentication port, 9099 by default):

Terminal window
curl -X POST 'http://127.0.0.1:9099/v1/projects/demo-app/apps/1%3A1234567890%3Aweb%3Alocal-test-app:exchangeDebugToken' \
-H 'content-type: application/json' \
-d "{\"debugToken\":\"$(uuidgen | tr 'A-F' 'a-f')\",\"limitedUse\":false}"
# {"token":"eyJhbGciOiJSUzI1NiIs...", "ttl": "..."}

A string that is not a UUIDv4 is refused: 403 PERMISSION_DENIED, App attestation failed.

appCheck.debugTokens controls this. Setting it to "any" yourself next to appCheck.enabled: true also lets any UUIDv4 exchange for the apps you registered, whatever digests they carry. It is "any" in that case and "registered", production’s rule, under productionExact, under the emulator profile, and in any appCheck block that does not set the key. With "registered" nothing is served until you enable App Check as below.

To enforce App Check for Authentication, Firestore or Storage, or to require registration, enable it and register the app with the SHA-256 digests of its debug tokens. The configuration stores only digests, never a raw token.

# Configuration schema version: 1.
schemaVersion = 1
# Compatibility profile: "strict" or "emulator".
profile = "strict"
[appCheck]
# Enable the App Check product baseline: true or false.
enabled = true
[[appCheck.apps]]
# App project ID: starts with a lowercase letter; up to 63 lowercase letters, digits, or hyphens.
projectId = "demo-app"
# App project number: a positive decimal string of up to 32 digits.
projectNumber = "1234567890"
# App ID: a string of 1 to 256 characters.
appId = "1:1234567890:web:local-test-app"
# Registered debug token digests: up to 128 unique lowercase SHA-256 hex strings
# (this one is the digest of the example token 2f1e6d3c-5b7a-4c1e-9d0a-8e4b7c6a1f23; use your own)
debugTokenSha256 = ["ceab494aa80e99ae2521e0a8c7b7a3b2d8fbe38e725ad31a29a888b997cfa4a5"]
[appCheck.services]
# Auth App Check mode: "off", "unenforced", or "enforced".
auth = "unenforced"
# Firestore App Check mode: "off", "unenforced", or "enforced".
firestore = "enforced"
# Storage App Check mode: "off", "unenforced", or "enforced".
storage = "enforced"

--only keeps the verifier a configured baseline needs: with appCheck.services.firestore = "enforced", --only firestore still refuses a request without a valid token, and the start banner says that the verifier was included.

Each service accepts off, unenforced (tokens are checked and recorded, requests still pass) or enforced (requests without a valid token are refused before Security Rules run). A project without a registered app cannot use App Check, so an enforced service refuses every request to it.

With Storage enforced, server-side code that uses the Admin SDK or gcloud (a client library, not a browser) still has to reach Storage without a token. Production does not apply App Check to that traffic: the Admin SDK authenticates with a service account over OAuth, and enforcement covers the Firebase Storage API the client SDKs call. The emulator has no OAuth, so appCheck.storageServerRequests decides how it recognises that traffic:

"loopback" (default) "capability"
Admitted without a token A Cloud Storage JSON API request from a loopback peer to a loopback Host, with no browser evidence and no client SDK credential (details) Only a request that carries the per-run capability fireemu exec puts in the userinfo of STORAGE_EMULATOR_HOST (http://fireemu:<capability>@host:port)
STORAGE_EMULATOR_HOST The plain http://host:port, which every @google-cloud/storage and firebase-admin version accepts With the capability. @google-cloud/storage 8.1 and later (firebase-admin 14.4 and later) refuse a URL with embedded credentials
file.save() and resumable uploads Work: a resumable continuation is such a request The Admin SDK fails under enforcement with those versions or resumable uploads: a continuation URL carries no capability
A local non-browser process (curl, a test script) Passes without a token Refused without the capability
What an enforced Storage service proves Not that a local client presented a token That nothing but the capability got through
A container reaching the emulator through a non-loopback host name (host.containers.internal, a published port on a LAN address) Not recognised: needs a token or "capability" Needs the capability
The Admin SDK inside a Cloud Function you run under fireemu Passes: it gets the same plain STORAGE_EMULATOR_HOST (in production it uses the function’s service account, which App Check does not check) Does not pass

The Firebase Storage API (/v0, what firebase/storage and the mobile SDKs use) always needs a token, and so does any request that looks like it came from a page or from another host. It is the same under the emulator and strict profiles and under productionExact, because it is production’s behaviour. Without Storage enforcement STORAGE_EMULATOR_HOST is always the plain http://host:port.

How “loopback” recognises a request

"loopback" admits a request on a Cloud Storage JSON API route (/storage/v1, /upload/storage/v1, /download/storage/v1) that:

  • comes from a loopback peer;
  • addresses a loopback Host (localhost or a loopback IP address such as 127.0.0.1 or [::1]; a rebound DNS name such as evil.example is not);
  • carries no browser evidence: no Origin, Sec-Fetch-Site, Sec-Fetch-Dest, Referer or Cookie header with a non-empty value (an empty one counts as absent);
  • does not present the client SDK’s Authorization: Firebase <token> credential.

The short /b/{bucket} spelling the Admin SDK uses for bucket metadata counts as a JSON API route. A resumable upload continuation is such a request, so file.save() works. fireemu exec exports the plain STORAGE_EMULATOR_HOST=http://host:port.

# Configuration schema version: 1.
schemaVersion = 1
[appCheck]
# Enable the App Check product baseline: true or false.
enabled = true
# Storage server request admission: "loopback" or "capability".
storageServerRequests = "capability"
[appCheck.services]
# Storage App Check mode: "off", "unenforced", or "enforced".
storage = "enforced"

A debug token is a UUIDv4, and its digest is taken over the lowercase form. Generate a token for tests and compute its digest:

Terminal window
DEBUG_TOKEN=$(uuidgen | tr 'A-F' 'a-f')
printf %s "$DEBUG_TOKEN" | shasum -a 256 | cut -d' ' -f1

Any other string never matches, and the exchange answers 403.

@firebase/app-check always calls the production exchange endpoint, so use a CustomProvider that exchanges the debug token with Fireemu at POST /v1/projects/{project}/apps/{appId}:exchangeDebugToken. The host comes from FIREEMU_APP_CHECK_EMULATOR_HOST: fireemu exec sets it for your command, and fireemu up prints it in the startup output (the address of the control listener, which is the Authentication port, 9099 by default, when Authentication is selected). A browser bundle cannot read process.env, so give it the host through your bundler’s own configuration.

import { initializeApp } from 'firebase/app';
import {
CustomProvider,
getToken,
initializeAppCheck,
type AppCheckTokenResult,
} from 'firebase/app-check';
const APP_ID = '1:1234567890:web:local-test-app';
const host = process.env.FIREEMU_APP_CHECK_EMULATOR_HOST;
const debugToken = process.env.DEBUG_TOKEN;
if (!host || !debugToken) throw new Error('Set FIREEMU_APP_CHECK_EMULATOR_HOST and DEBUG_TOKEN');
async function exchangeDebugToken(): Promise<AppCheckTokenResult> {
const url = `http://${host}/v1/projects/demo-app/apps/${encodeURIComponent(APP_ID)}:exchangeDebugToken`;
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ debugToken, limitedUse: false }),
});
if (!response.ok) throw new Error(`exchange refused: ${response.status}`);
const body = (await response.json()) as { token: string; ttl: string };
const ttlSeconds = Number.parseInt(body.ttl.replace(/s$/, ''), 10);
return { token: body.token, expireTimeMillis: Date.now() + ttlSeconds * 1000 };
}
const app = initializeApp({ projectId: 'demo-app', apiKey: 'fake-api-key', appId: APP_ID });
const appCheck = initializeAppCheck(app, {
provider: new CustomProvider({ getToken: exchangeDebugToken }),
});
const { token } = await getToken(appCheck);

Run it with the variables set. Under fireemu exec only DEBUG_TOKEN is yours:

Terminal window
export DEBUG_TOKEN=$(uuidgen | tr 'A-F' 'a-f')
# fireemu exec sets FIREEMU_APP_CHECK_EMULATOR_HOST for the command
npx fireemu exec -- node provider.mjs
# with fireemu up running, copy the host from its startup output
FIREEMU_APP_CHECK_EMULATOR_HOST=127.0.0.1:9099 node provider.mjs

The TypeScript version ran against a local fireemu up 0.48.0 with firebase 12.18.0 and Node.js 24 (type stripping), and getToken returned a token. With no appCheck block any UUIDv4 exchanges; with registered apps the token’s digest must be one of the app’s debugTokenSha256 values.

enforceAppCheck: true on a Node callable refuses a call without a valid token with the callable UNAUTHENTICATED error before the function runs (a 401 response, or the 200 SSE error record for a streaming call), and a valid token fills request.app. Replay protection is not supported: a call that presents a valid token to a callable with consumeAppCheckToken: true is refused with 501 APP_CHECK_REPLAY_UNSUPPORTED, so a test can never pass on a token that production would have consumed.

Tokens are signed with a key that belongs to one daemon instance. Node callables and other daemon-verified services reject them after a session reset or snapshot restore. Python callables verify token signatures and claims using the signing keys and real time, without checking current app state or project epoch, so resetting a project or disabling an app does not revoke an otherwise valid token before expiry. Python also refuses token consumption and explicit App Check configuration; see Cloud Functions (Python) for setup and restrictions.

Unlike a callable, an onRequest handler receives the X-Firebase-AppCheck header without automatic verification or request.app. Your handler must verify it and decide what a verified app may do. enforceAppCheck on a callable does not protect a separate HTTP endpoint.

Verify with the Admin SDK (getAppCheck().verifyToken())

Section titled “Verify with the Admin SDK (getAppCheck().verifyToken())”

The code you deploy works unchanged: getAppCheck().verifyToken(token) inside an onRequest function is answered by the daemon, not by Google’s keys.

import { onRequest } from 'firebase-functions/v2/https';
import { initializeApp } from 'firebase-admin/app';
import { getAppCheck } from 'firebase-admin/app-check';
initializeApp();
export const api = onRequest(async (req, res) => {
try {
const { appId } = await getAppCheck().verifyToken(req.get('X-Firebase-AppCheck'));
// application logic for a verified app
} catch {
res.status(401).send('Unauthorized');
}
});

The runner patches the firebase-admin it resolves from your codebase before your code loads. The SDK still decodes the token and checks the algorithm, audience, issuer and subject itself; only the signature step is replaced by a question to the daemon over the runner’s private pipe. The daemon answers with the same verifier it uses for callable functions, against the current project epoch and app registry, on the virtual clock, and nothing is cached. A token minted before an in-place session reset or snapshot restore is therefore rejected, exactly like a token the daemon no longer holds the key for (app-check/invalid-argument, “… does not correspond to a known public key …”). Errors are the SDK’s own, produced by its own error mapper.

  • Supported: firebase-admin 13 and 14, resolved from the Functions codebase. Tested with 14.3.0 (CI) and 13.10.0; 14.5.0 is not installed in CI because its App Check code is identical to 14.3.0 (the files token-verifier.js, app-check.js, app-check-api-client-internal.js and utils/jwt.js differ only in the version banner), and a test pins the content of those files so a dependency bump that changes them fails CI. Outside that range, or if the SDK’s internals changed so that the startup self-test fails, nothing is patched, the daemon prints a startup note, and verifyToken rejects Fireemu tokens (it never accepts one it cannot check). A second copy of firebase-admin elsewhere in node_modules is not the one the runner resolves and stays stock.
  • verifyToken(token, { consume: true }) is refused with app-check/unimplemented after the token verified. Limited-use consumption is not implemented, and the production App Check API is never called. getAppCheck().createToken() is a different call and still goes to Google’s exchange endpoint.
  • The daemon verifies for the project the Functions runner belongs to. A call whose app names another project ID is answered as an unknown key.
  • Divergence from production: production accepts a token for an app that is not registered or is disabled until the token expires. Fireemu rejects it, in every profile. A token whose iat is in the future verifies, as in production.
  • Nothing here makes onRequest verify automatically. Your handler still decides what a verified app may do, and App Check identifies an app, not a user.
Process Available routing
fireemu exec child or world.environment FIREEMU_APP_CHECK_EMULATOR_HOST and FIREEMU_APP_CHECK_JWKS_URL when App Check is available. The JWKS URL is http://host:port/v1/jwks.
Bundled Functions runner FUNCTIONS_EMULATOR=true and the same App Check host/JWKS variables when App Check is available, even when Auth is not selected. Inherited, dotenv or local secret values cannot replace these two routes; they are removed when App Check is unavailable.
Parent test process World variables are not installed into process.env; use world.environment explicitly.

Use FIREEMU_APP_CHECK_JWKS_URL inside the runner. Do not derive the key URL from a request header or an unverified token. This example deliberately supports loopback only.

Local signature and claim verification without the Admin SDK

Section titled “Local signature and claim verification without the Admin SDK”

If your middleware verifies with a JWKS library instead of the Admin SDK, install jose in the Functions package. The project number, project ID and allowed app IDs below must come from your test configuration. The allowed app set must reflect the enabled apps you intend this endpoint to admit.

import { createRemoteJWKSet, jwtVerify } from 'jose';
import { getAppCheck } from 'firebase-admin/app-check';
const projectId = 'demo-app';
const projectNumber = '1234567890';
const allowedApps = new Set(['1:1234567890:web:local-test-app']);
let localKeys;
export async function verifyHttpAppCheck(token) {
if (process.env.FUNCTIONS_EMULATOR !== 'true') {
return getAppCheck().verifyToken(token);
}
if (!localKeys) {
const address = process.env.FIREEMU_APP_CHECK_JWKS_URL;
if (!address) throw new Error('Local App Check is unavailable');
const url = new URL(address);
if (url.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(url.hostname) || !url.port ||
url.username || url.password || url.pathname !== '/v1/jwks' || url.search || url.hash) {
throw new Error('Expected the loopback App Check JWKS URL');
}
localKeys = createRemoteJWKSet(url);
}
const now = new Date();
const { payload, protectedHeader } = await jwtVerify(token, localKeys, {
algorithms: ['RS256'],
issuer: `https://firebaseappcheck.googleapis.com/${projectNumber}`,
requiredClaims: ['sub', 'aud', 'iat', 'exp', 'jti'],
currentDate: now,
clockTolerance: 0,
});
const audiences = [`projects/${projectNumber}`, `projects/${projectId}`];
if (protectedHeader.typ !== 'JWT' ||
typeof protectedHeader.kid !== 'string' || !protectedHeader.kid ||
!Array.isArray(payload.aud) || !audiences.every(a => payload.aud.includes(a)) ||
!allowedApps.has(payload.sub) ||
!Number.isInteger(payload.iat) || !Number.isInteger(payload.exp) ||
payload.iat > Math.floor(now.getTime() / 1000) || payload.exp <= payload.iat ||
typeof payload.jti !== 'string' || !payload.jti) {
throw new Error('Invalid local App Check token');
}
return { ...payload, app_id: payload.sub };
}

Call this from your onRequest handler with req.get('X-Firebase-AppCheck'). Reject a missing header or verification failure with a generic 401 before running application logic. Do not return verification details or log tokens. App Check identifies an app, not a user: keep your Auth and membership checks as well.

The example’s new Date() must use the same clock as token exchange. Test worlds default to virtual Date in Functions; a plain daemon using a pinned clock also needs functions.clock.date = "virtual". The parent test process and world.run children retain native Date, so copying this verifier there requires explicitly supplying the world time.

This is signature and claim verification, not Fireemu’s full verifier. A JWKS does not expose the current project epoch or app registry; the Admin SDK path above does. The example cannot reject a previously issued token after an in-place session reset, snapshot restore, project deletion or app disablement while the same signing key remains usable. Checking only that fireemu_epoch exists would not fix this. It also does not implement token consumption or replay protection.

For tests using this recipe, recreate the whole world with world.reset() (or dispose and create another world), including the Functions runner, and obtain a fresh token. That replaces the daemon keys and verifier cache. Do not treat this workaround as proof of the daemon’s epoch invalidation behavior; use its callable verification boundary to test that behavior.

Firestore WebChannel App Check refusals arrive as a terminal PERMISSION_DENIED protocol error after HTTP 200 transport negotiation, unlike a REST refusal with HTTP 403. An invalid or different-app replacement token on an existing channel cannot replay retained documents. See Listen streams.