App Check
The Local Emulator Suite has no App Check. 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.
Local App Check uses debug tokens. Real attestation through Play Integrity, App Attest, DeviceCheck or reCAPTCHA, and replay protection with limited-use tokens, are not supported.
Without any configuration
Section titled “Without any 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.
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.
Register an app
Section titled “Register an app”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.
schemaVersion = 1profile = "strict"
[appCheck]enabled = true
[[appCheck.apps]]projectId = "demo-app"projectNumber = "1234567890"appId = "1:1234567890:web:local-test-app"debugTokenSha256 = ["<sha256 of your debug token>"]
[appCheck.services]auth = "unenforced"firestore = "enforced"storage = "enforced"{ "schemaVersion": 1, "profile": "strict", "appCheck": { "enabled": true, "apps": [ { "projectId": "demo-app", "projectNumber": "1234567890", "appId": "1:1234567890:web:local-test-app", "debugTokenSha256": [ "<sha256 of your debug token>" ] } ], "services": { "auth": "unenforced", "firestore": "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"(the default) 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 loopbackHost(localhost,127.0.0.1,[::1]; a rebound DNS name such asevil.exampleis not), carries no browser evidence (noOrigin,Sec-Fetch-Site,Sec-Fetch-Dest,RefererorCookieheader, whatever its value) and does not present the client SDK’sAuthorization: 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, sofile.save()works.fireemu execexports the plainSTORAGE_EMULATOR_HOST=http://host:port, which every@google-cloud/storageandfirebase-adminversion accepts. The Firebase Storage API (/v0, whatfirebase/storageand 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 underproductionExact, because it is production’s behaviour.
The trade-off: with "loopback", any local non-browser process (curl, a test script, a client app that misuses the JSON API) passes without a token, so an enforced Storage service no longer proves that a local client presented one. Use "capability" for that guarantee. A container that reaches the emulator through a non-loopback host name (host.containers.internal, a published port on a LAN address) is not recognised either, so it needs a token or "capability". The Admin SDK inside a Cloud Function you run under fireemu gets the same plain STORAGE_EMULATOR_HOST, so it passes too (in production it uses the function’s service account, which App Check does not check); with "capability" it does not.
"capability"admits only a per-run capability thatfireemu execputs in the userinfo ofSTORAGE_EMULATOR_HOST(http://fireemu:<capability>@host:port).@google-cloud/storage8.1 and later (firebase-admin14.4 and later) refuse a URL with embedded credentials, and a resumable continuation URL carries no capability, so with those versions or resumable uploads the Admin SDK fails under enforcement. Use it to prove that nothing but the capability can get through.
Without Storage enforcement STORAGE_EMULATOR_HOST is always the plain http://host:port.
schemaVersion = 1
[appCheck]enabled = truestorageServerRequests = "capability"
[appCheck.services]storage = "enforced"{ "schemaVersion": 1, "appCheck": { "enabled": true, "storageServerRequests": "capability", "services": { "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:
DEBUG_TOKEN=$(uuidgen | tr 'A-F' 'a-f')printf %s "$DEBUG_TOKEN" | shasum -a 256 | cut -d' ' -f1Any other string never matches, and the exchange answers 403.
Get a token from the Web SDK
Section titled “Get a token from the Web SDK”@firebase/app-check always calls the production exchange endpoint, so use a CustomProvider that exchanges the debug token with Fireemu. fireemu exec sets FIREEMU_APP_CHECK_EMULATOR_HOST:
import { initializeApp } from 'firebase/app';import { CustomProvider, initializeAppCheck } from 'firebase/app-check';
const APP_ID = '1:1234567890:web:local-test-app';const host = process.env.FIREEMU_APP_CHECK_EMULATOR_HOST;
async function exchangeDebugToken() { 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: process.env.DEBUG_TOKEN, limitedUse: false }), }); const body = await response.json(); const ttlSeconds = Number.parseInt(String(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 });initializeAppCheck(app, { provider: new CustomProvider({ getToken: exchangeDebugToken }) });Callable functions
Section titled “Callable functions”enforceAppCheck: true on a callable refuses a call without a valid token with 401 before the function runs, 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 and are invalidated by a session reset or snapshot restore, so a token does not carry over between runs.
Ordinary HTTP functions (onRequest)
Section titled “Ordinary HTTP functions (onRequest)”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.
The Node Admin SDK’s getAppCheck().verifyToken() uses Google’s JWKS, including in firebase-admin 14.5.0; it has no App Check emulator host switch. A token signed by Fireemu therefore fails that verification. See the Admin SDK verifier. Keep the Admin SDK verification path in production.
Endpoint discovery in each process
Section titled “Endpoint discovery in each process”| 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. In v0.23.0 the runner does not receive the App Check variables: selecting Auth and deriving /v1/jwks from FIREBASE_AUTH_EMULATOR_HOST is a version-specific workaround, not a requirement of App Check itself.
Local signature and claim verification example
Section titled “Local signature and claim verification example”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 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.