HomeGuidesFirebase Cloud Functions auth: onCall, onRequest and App Check, tested
Guide
Firebase Cloud Functions auth: onCall, onRequest and App Check, tested
Why request.auth is null, why onRequest functions are open to anyone with the URL, how to verify ID tokens, and what enforceAppCheck misses. Emulator tested.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
You asked Cursor, Lovable, Bolt or Claude Code for a Firebase backend, and it gave you Cloud Functions: a callable getInvoices, an HTTP exportCsv, maybe a setPlan after checkout. They work in the app, so they look private. They are not. Every HTTPS function you deploy gets a public URL, and Firebase does not check who is calling unless your code does.
This guide covers the auth mistakes we see in AI-written Cloud Functions: callables that never check request.auth, onRequest endpoints with no token check, functions that trust a uid from the request, the Admin SDK skipping your rules, and App Check and CORS being mistaken for auth. Each one was tested in the Firebase Local Emulator Suite, and the token checks were also run through the production code path of firebase-functions outside the emulator.
Tested on October 30, 2026 with firebase-tools 15.34.2 (Functions, Auth and Firestore emulators, Firestore emulator v1.22.0), firebase-functions 7.4.0, firebase-admin 13.10.0 and the firebase JS SDK 13.1.0 on Node 22.20.0, 2nd gen (v2) functions plus one 1st gen callable. We did not deploy to a hosted Firebase project and did not use real App Check attestation, so anything about deployed IAM, real App Check tokens and replay protection comes from Firebase's docs and source code, and we say so where it matters. VibeLocked is independent and not affiliated with Google or Firebase.
The short version
onCalldoes not require sign in. No token meansrequest.authisnulland your code still runs. Check it.onRequesthas no auth at all. Anyone with the URL can call it. Verify the ID token yourself withverifyIdToken(token, true).- Take the uid from the token, never from
request.dataor the body. - The Admin SDK ignores Security Rules. Inside a function, your code is the rule.
- Check roles with custom claims in
request.auth.token, and validate every input. onCalldoes not check revocation. For sensitive actions, verifyrequest.auth.rawTokenwithcheckRevoked.- App Check is not auth.
enforceAppCheckblocks scripts without a token, not signed out users.consumeAppCheckTokendoes not reject a reused token by itself. - CORS is not access control, and HTTPS functions are deployed public by default.
1. onCall vs onRequest: who checks the token
Firebase has two kinds of HTTPS functions, and AI tools mix them up.
onCall (callable) |
onRequest (HTTP) |
|
|---|---|---|
| Called with | The Functions client SDK, httpsCallable(functions, 'name') |
Any HTTP client: fetch, curl, a webhook |
| ID token | Sent by the client SDK when a user is signed in, verified for you, put in request.auth |
Nothing. You read and verify the header yourself |
| No token sent | Function runs, request.auth is null |
Function runs, there is no auth field at all |
| App Check | enforceAppCheck option |
Verify the header yourself if you want it |
| CORS default | All origins | No CORS headers unless you set cors |
The callable protocol page says the Authorization header is optional and that an invalid token is rejected. Optional is the part that matters: a missing token is not an error. In the emulator, a signed out call to a callable and a plain POST with no headers both returned 200 with request.auth set to null:
Request to a v2 callable that returns request.auth?.uid |
Result |
|---|---|
| Client SDK, signed out | 200, uid: null |
| Plain POST, no headers | 200, uid: null |
| Client SDK, signed in | 200, the user's uid |
Authorization: Basic x (not a Bearer token) |
401 UNAUTHENTICATED |
1st gen functions.https.onCall((data, context) => ...), signed out |
200, no context.auth, uid: null |
So "callable functions verify auth for you" is half true. They verify a token if there is one. If there is not, the request is anonymous and it is up to you.
2. Check request.auth in every callable
The fix is one line at the top of the handler. Firebase's callable guide shows the same check:
import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { getFirestore } from 'firebase-admin/firestore';
export const getInvoices = onCall(async (request) => {
if (!request.auth) {
throw new HttpsError('unauthenticated', 'Sign in first.');
}
const uid = request.auth.uid; // from the verified token, not from request.data
const snap = await getFirestore()
.collection('invoices')
.where('owner', '==', uid)
.get();
return { invoices: snap.docs.map((d) => ({ id: d.id, ...d.data() })) };
});
On 1st gen functions the same data is on context.auth in onCall((data, context) => ...). Throw an HttpsError rather than returning an error object: the client gets the code (functions/unauthenticated in our test) and the status (401). Any other thrown error reaches the client as internal.
If you see request.auth is null for a signed in user, check that the app calls the function through httpsCallable from the same Firebase app instance the user signed in with. A hand-written fetch to the function URL sends no token.
3. Never take the uid from the request
The most common bug we see is a callable that takes uid as an argument because the AI wanted a reusable function:
// BAD: anyone can pass any uid
export const getInvoicesBad = onCall(async (request) => {
const snap = await db.collection('invoices').where('owner', '==', request.data.uid).get();
return { invoices: snap.docs.map((d) => d.data()) };
});
Our Firestore rules allowed each user to read only their own invoices, and a direct client read of the whole collection was denied. The function did not care:
| Call | Result |
|---|---|
Client SDK reads all invoices directly (rules apply) |
Denied, permission-denied |
getInvoicesBad signed out, { uid: <bob> } |
200, Bob's invoice |
getInvoicesBad as Alice, { uid: <bob> } |
200, Bob's invoice |
Fixed getInvoices signed out |
401 unauthenticated |
Fixed getInvoices as Alice, still sending { uid: <bob> } |
200, only Alice's invoice |
User ids are not secrets. They show up in URLs, document paths and shared links. A function that accepts one is a way around every rule you wrote.
4. The Admin SDK skips your rules, so the function is the rule
Cloud Functions talk to Firestore with the Admin SDK. Firebase's Security Rules get started page says "the server client libraries bypass all Cloud Firestore Security Rules." Your carefully written allow write: if false means nothing to a function.
We set users/{uid} to allow write: if false so only the server could change a user's plan. Then we called a setPlanBad callable that does db.doc('users/' + request.data.uid).set({ plan: request.data.plan }, { merge: true }):
| Write | Result |
|---|---|
Alice writes plan: 'pro' to her own document from the client |
Denied by the rule |
setPlanBad signed out, { uid: <alice>, plan: 'pro' } |
200, Alice's document now says plan: "pro" |
Every check your rules make for clients, the function has to make again in code: who is calling, what role they have, which documents they may touch, and what values are allowed.
5. Roles: custom claims plus input checks
For admin-only functions, check a custom claim on the verified token and validate the input:
export const setPlan = onCall(async (request) => {
if (!request.auth) throw new HttpsError('unauthenticated', 'Sign in first.');
if (request.auth.token.admin !== true) {
throw new HttpsError('permission-denied', 'Admins only.');
}
const { uid, plan } = request.data ?? {};
if (typeof uid !== 'string' || !['free', 'pro'].includes(plan)) {
throw new HttpsError('invalid-argument', 'uid and plan (free or pro) required.');
}
await getFirestore().doc(`users/${uid}`).set({ plan }, { merge: true });
return { ok: true };
});
Call to setPlan |
Result |
|---|---|
| Signed out | 401 unauthenticated |
| Alice, no admin claim | 403 permission-denied |
| Bob, admin claim set on the server but his token not refreshed yet | 403 permission-denied |
Bob after getIdToken(true) |
200 |
Bob with plan: 'enterprise' |
400 invalid-argument |
The third row is the same late arrival covered in Firebase custom claims for admin roles: a new claim only reaches request.auth.token after the client's ID token refreshes. That guide also covers why a role field the user can write is not a role, why setCustomUserClaims wipes other claims, and how to make the first admin safely.
For a plan or payment state, do not let a callable set it at all. Set it from your verified payment webhook; see securing Stripe webhooks.
6. onRequest: verify the ID token yourself
onRequest gives you a plain Express style request. There is no req.auth; in our test it was undefined even when the request carried a valid Bearer token. The function below (an AI favorite for CSV exports) answered a bare GET with every invoice in the database:
// BAD: no auth, the cors list does not help (section 9)
export const exportInvoicesBad = onRequest({ cors: ['https://myapp.example'] }, async (req, res) => {
const snap = await db.collection('invoices').get();
res.json(snap.docs.map((d) => d.data()));
});
Verify the token with the Admin SDK, as Firebase's verify ID tokens page shows:
import { onRequest } from 'firebase-functions/v2/https';
import { getAuth } from 'firebase-admin/auth';
export const exportInvoices = onRequest({ cors: ['https://myapp.example'] }, async (req, res) => {
const match = (req.get('Authorization') || '').match(/^Bearer (.+)$/);
if (!match) {
res.status(401).json({ error: 'missing token' });
return;
}
let decoded;
try {
decoded = await getAuth().verifyIdToken(match[1], true); // true = also reject revoked sessions
} catch {
res.status(401).json({ error: 'invalid token' });
return;
}
const snap = await getFirestore().collection('invoices').where('owner', '==', decoded.uid).get();
res.json(snap.docs.map((d) => d.data()));
});
On the client, send the token from the signed in user: headers: { Authorization: 'Bearer ' + await auth.currentUser.getIdToken() }.
Request to the fixed exportInvoices |
Result |
|---|---|
No Authorization header |
401 |
Bearer garbage |
401 (auth/argument-error) |
| Alice's ID token | 200, only Alice's invoices |
Alice's old token after revokeRefreshTokens(aliceUid) |
401 (auth/id-token-revoked) |
The second argument true is checkRevoked. Firebase's docs note that plain verifyIdToken does not check whether the token was revoked, so without it a signed out or banned user's token keeps working until it expires (up to an hour). It costs an extra Auth lookup per request, so it fits admin and money routes best. With the Auth emulator on, firebase-admin checks revocation even without true, so a missing true will not show up in emulator tests; read your code for it.
If the function is a webhook from Stripe, Clerk or GitHub, there is no user token. Verify the provider's signature instead; see verify webhooks.
7. Callables do not check revocation
This one surprised us. The callable trigger in firebase-functions verifies the ID token with verifyIdToken(idToken), without checkRevoked, in src/common/providers/https.ts. So after you revoke a user's sessions, a token they already hold still passes request.auth until it expires.
For account deletion, payouts, role changes and similar, check revocation yourself with the raw token the trigger keeps for you:
export const deleteMyData = onCall(async (request) => {
if (!request.auth) throw new HttpsError('unauthenticated', 'Sign in first.');
try {
await getAuth().verifyIdToken(request.auth.rawToken, true);
} catch {
throw new HttpsError('unauthenticated', 'Session revoked. Sign in again.');
}
// ... sensitive work for request.auth.uid
});
Old token after revokeRefreshTokens(uid), in the emulator |
Result |
|---|---|
Plain callable that reads request.auth.uid |
200, still signed in |
deleteMyData with the rawToken check |
401 Session revoked |
The first row is partly an emulator effect (the Functions emulator does not verify tokens at all, see the test notes below), but the source shows production callables skip the revocation check too.
8. App Check is not auth
App Check attests that a request came from your registered app on a real device or browser. Firebase calls it and Auth "complementary": Auth protects your users, App Check "protects you, the developer." With enforceAppCheck: true a callable rejects requests that have no valid App Check token:
export const generateSummary = onCall({ enforceAppCheck: true }, async (request) => {
if (!request.auth) throw new HttpsError('unauthenticated', 'Sign in first.');
// request.app.appId is your registered app
});
What we saw:
| Request | Where | Result |
|---|---|---|
enforceAppCheck, no App Check header |
Emulator and production code path | 401 UNAUTHENTICATED |
enforceAppCheck, X-Firebase-AppCheck: garbage |
Production code path | 401 |
enforceAppCheck, unsigned App Check token |
Production code path | 401 (Expected "RS256" but got "none") |
enforceAppCheck, App Check token, signed out |
Emulator | 200, request.auth is null |
No enforceAppCheck, X-Firebase-AppCheck: garbage |
Production code path | 200, request.app is null |
Three things follow:
- A valid App Check token says nothing about the user. The handler ran for a signed out caller. Keep the
request.authcheck. - Without
enforceAppCheck, a bad token is ignored, not rejected. The source logs "Allowing request with invalid AppCheck token because enforcement is disabled" and runs your function. Registering your app with App Check, or enforcing it for Firestore in the console, does not change your callables; the App Check for Cloud Functions page turns enforcement on with this option in code. enforceAppCheckis a callable option only.onRequestfunctions get nothing from it. If you want App Check on an HTTP function, verify the header withgetAppCheck().verifyToken()as in Firebase's custom backend page.
The App Check for Cloud Functions page also describes replay protection: consumeAppCheckToken: true with clients asking for limited use tokens (limitedUseAppCheckTokens: true on the web). The option's own docs in firebase-functions say a reused token is not rejected automatically: "the request.app.alreadyConsumed property will be set to true and pass the execution to the handler code." So check it:
export const redeemCode = onCall(
{ enforceAppCheck: true, consumeAppCheckToken: true },
(request) => {
if (request.app?.alreadyConsumed) {
throw new HttpsError('permission-denied', 'Token already used.');
}
// ...
},
);
Consuming tokens adds a network round trip and, per Firebase, needs the "Firebase App Check Token Verifier" role on the function's service account. We could not test replay protection: the emulator decodes App Check tokens without verifying or consuming them, so the same token came back alreadyConsumed: false twice. For why the Firebase API key in your bundle is fine and what App Check adds on top of rules, see is it safe to expose your Firebase API key.
9. CORS is not access control, and functions are public by default
AI tools often "secure" a function by setting cors to the app's domain. CORS is a browser rule about which pages may read a response. It does not stop a request, and it does nothing to curl, scripts or servers. We ran an onRequest function with cors: ['https://myapp.example'] through the production code path:
| Request | Result |
|---|---|
No Origin header (curl, a script) |
200, data returned |
Origin: https://evil.example |
200, data returned (the response still names only myapp.example, so a browser on evil.example would hide it) |
POST from https://evil.example |
200, function ran |
Preflight OPTIONS from https://evil.example |
204 |
So the function ran for everyone. Keep the cors list to stop other sites' pages from reading responses in a browser, and put the token check in front of the data.
Invoker. Firebase deploys HTTPS functions publicly invokable unless you say otherwise. In firebase-tools the deploy step uses endpoint.httpsTrigger.invoker || ["public"] for HTTP functions and always sets ["public"] for callables (fabricator.ts). The invoker option accepts "public", "private" or service accounts. A private function needs a Google IAM identity to call it, per Cloud Run's access control docs; that suits server to server calls such as a scheduler, not your app's signed in users, whose Firebase ID tokens are not IAM identities. For anything your app calls, assume the URL is public and that a stranger will find it in your bundle. We read this in the source and docs; we did not deploy to check IAM.
Test notes: what the emulator does not show you
The Functions emulator sets a debug flag (skipTokenVerification) that makes firebase-functions decode tokens without verifying them (functionsEmulator.ts). In our emulator run:
- A forged unsigned token (
alg: none) claiming to be Bob withadmin: truegot200from a callable, withrequest.auth.uidset to Bob andadmin: true.Bearer garbagealso got200. verifyIdTokenin ouronRequestfunction also accepted the forged token, becausefirebase-adminaccepts unsigned tokens whileFIREBASE_AUTH_EMULATOR_HOSTis set.- Unsigned or garbage App Check tokens passed
enforceAppCheck. - The emulator also turns CORS on for every origin, even with a
corslist.
That is expected for local testing, but it means a passing emulator test proves your checks run, not that tokens are verified. To check the real path, we ran the same callable and onRequest handlers outside the emulator (plain Express, no debug flags, no emulator env vars). There the forged token got 401 ("Firebase ID token has no kid claim"), garbage got 401, and the App Check results are the "production code path" rows above. Never set FIREBASE_AUTH_EMULATOR_HOST or the emulator debug variables in a deployed function's environment.
What we tested and what we did not
Tested in the Firebase Local Emulator Suite (Functions, Auth, Firestore) with 11 functions: v2 onCall with and without the request.auth check, a 1st gen onCall with context.auth, a callable trusting request.data.uid, Admin SDK writes past a deny-all rule, a custom claim role check with input validation, onRequest with and without verifyIdToken(token, true), revocation after revokeRefreshTokens, the rawToken revocation check, enforceAppCheck and consumeAppCheckToken. Outside the emulator, through firebase-functions 7.4.0 production code paths in Express: missing, garbage and forged ID tokens, enforceAppCheck with missing, garbage and unsigned App Check tokens, a bad App Check token without enforcement, and the cors option.
Not tested: a hosted Firebase project or deployed functions, Cloud Run IAM and the invoker setting (source and docs only), real App Check attestation with reCAPTCHA Enterprise, App Attest or Play Integrity, replay protection with real limited use tokens (the emulator does not consume them), production token signing and expiry in real time, Firebase Hosting rewrites to functions, Python functions, background triggers, and the deprecated authPolicy option.
Checklist
- Every callable that reads or writes user data starts with
if (!request.auth) throw new HttpsError('unauthenticated', ...) - No function takes a
uid,userIdorownerIdfromrequest.data, the body or the query to decide whose data to use - Every
onRequestfunction that is not a public endpoint verifiesAuthorization: BearerwithverifyIdToken(token, true) - Webhook functions verify the provider's signature instead
- Admin functions check a custom claim on
request.auth.token, never a role sent by the client - Every input is validated (type, allowed values, length) before an Admin SDK write
- Sensitive callables recheck
request.auth.rawTokenwithcheckRevoked -
enforceAppCheck: trueon callables your app uses, in addition to the auth check - Functions using
consumeAppCheckTokenrejectrequest.app.alreadyConsumed -
corslists your origins, and nobody treats it as access control - Plan, credits and payment state are written only from a verified webhook
- Emulator tests cover a signed out caller, a normal user, an admin and a user passing someone else's uid
- No emulator environment variables in deployed function config
Copy-paste prompt for your AI coding tool
Audit every Firebase Cloud Function in this repo for auth. Do not print or ask for any keys, tokens or service account values. 1) List every onCall and onRequest function (v1 and v2) with its file, options (enforceAppCheck, consumeAppCheckToken, cors, invoker) and what data it reads or writes. 2) For each callable, flag a missing request.auth or context.auth check, any uid, userId, ownerId or role taken from request.data, and missing checks of request.auth.token claims on admin actions. 3) For each onRequest function, flag a missing Authorization Bearer check with getAuth().verifyIdToken(token, true), any uid taken from the body or query, and webhooks without signature verification. 4) Flag Admin SDK writes that skip the checks the Firestore rules make for clients, unvalidated input, plan or credit changes outside a verified webhook, consumeAppCheckToken without an alreadyConsumed check, and any comment or code that treats CORS or App Check as access control. 5) Propose fixes: an auth check at the top of every callable, uid from the token only, claim checks for admin functions, verifyIdToken(token, true) in onRequest, request.auth.rawToken revocation checks for sensitive callables, enforceAppCheck on callables, and emulator tests for a signed out caller, a normal user, an admin and a user sending someone else's uid. Show the diff before applying anything.
More prompts: security review prompt for AI coding tools.
Check what your app exposes with LeakCheck
LeakCheck scans a live app URL or a public GitHub repo, read only. On a live app it reads the Firebase config your site ships, tests whether your database answers a stranger who is not signed in, and flags secrets such as a service account private key. It does not call your Cloud Functions or sign in, so it cannot tell whether a function checks request.auth or verifies tokens. A clean scan does not prove your functions are safe; the checklist and emulator tests above do that.
Sources
- Firebase: Call functions from your app (callable functions, request.auth, CORS)
- Firebase: Protocol specification for https.onCall (Authorization and X-Firebase-AppCheck headers)
- Firebase: Call functions via HTTP requests (onRequest, CORS defaults)
- Firebase: Verify ID tokens
- Firebase: Get started with Cloud Firestore Security Rules (server libraries bypass rules)
- Firebase: Enable App Check enforcement for Cloud Functions (enforceAppCheck, replay protection)
- Firebase App Check overview (App Check and Auth are complementary)
- Firebase: Verify App Check tokens from a custom backend
- Firebase reference: CallableOptions (enforceAppCheck, consumeAppCheckToken)
- Firebase reference: HttpsOptions (invoker)
- Google Cloud: Cloud Run access control with IAM
- Firebase: Connect your app to the Cloud Functions emulator
- GitHub: firebase-functions src/common/providers/https.ts (callable token checks)
- GitHub: firebase-functions src/v2/providers/https.ts (onCall and onRequest options)
- GitHub: firebase-tools fabricator.ts (default invoker on deploy)
- GitHub: firebase-tools functionsEmulator.ts (skipTokenVerification in the emulator)
Questions
Does a Firebase callable function require the user to be signed in?
No. onCall checks a token only if the request sends one. In our tests a call with no Authorization header ran the function with request.auth set to null, both in the emulator and in the production code path of firebase-functions 7.4.0. Start every callable that touches user data with if (!request.auth) throw new HttpsError('unauthenticated', ...).
Why is request.auth null in my onCall function?
Either the user is not signed in when the app calls the function, or the call does not go through the Firebase Functions client SDK, which adds the ID token for you. A plain fetch or curl sends no token, so request.auth is null and your function still runs. Treat null as signed out and reject the call.
How do I check auth in a Firebase onRequest function?
onRequest has no auth at all. Read the Authorization: Bearer header yourself, call getAuth().verifyIdToken(token, true) from firebase-admin, return 401 if it throws, and use the uid from the decoded token, never one from the request body or query string.
Does enforceAppCheck make my callable function private?
No. It rejects calls without a valid App Check token, which proves the call came from your registered app, not who the user is. In our test an enforceAppCheck callable ran for a signed out caller who had an App Check token. You still need the request.auth check. App Check only applies to callables; onRequest functions must verify the token themselves.
Do Firestore security rules apply inside Cloud Functions?
No. Cloud Functions use the Admin SDK, and Firebase says the server client libraries bypass all Cloud Firestore Security Rules. In our test a rule that denied every client write to users documents did not stop a callable from setting plan to pro for any uid it was given. The function has to do the checks the rule would have done.
Does CORS stop other people from calling my Cloud Function?
No. CORS only tells browsers whether a web page may read the response. In our test a function with cors set to one allowed origin still ran and returned its data to a request with no Origin header and to one from another origin. Scripts, curl and servers ignore CORS. Use auth checks for access control.