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.

17 min read

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

  1. onCall does not require sign in. No token means request.auth is null and your code still runs. Check it.
  2. onRequest has no auth at all. Anyone with the URL can call it. Verify the ID token yourself with verifyIdToken(token, true).
  3. Take the uid from the token, never from request.data or the body.
  4. The Admin SDK ignores Security Rules. Inside a function, your code is the rule.
  5. Check roles with custom claims in request.auth.token, and validate every input.
  6. onCall does not check revocation. For sensitive actions, verify request.auth.rawToken with checkRevoked.
  7. App Check is not auth. enforceAppCheck blocks scripts without a token, not signed out users. consumeAppCheckToken does not reject a reused token by itself.
  8. 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.auth check.
  • 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.
  • enforceAppCheck is a callable option only. onRequest functions get nothing from it. If you want App Check on an HTTP function, verify the header with getAppCheck().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 with admin: true got 200 from a callable, with request.auth.uid set to Bob and admin: true. Bearer garbage also got 200.
  • verifyIdToken in our onRequest function also accepted the forged token, because firebase-admin accepts unsigned tokens while FIREBASE_AUTH_EMULATOR_HOST is set.
  • Unsigned or garbage App Check tokens passed enforceAppCheck.
  • The emulator also turns CORS on for every origin, even with a cors list.

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, userId or ownerId from request.data, the body or the query to decide whose data to use
  • Every onRequest function that is not a public endpoint verifies Authorization: Bearer with verifyIdToken(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.rawToken with checkRevoked
  • enforceAppCheck: true on callables your app uses, in addition to the auth check
  • Functions using consumeAppCheckToken reject request.app.alreadyConsumed
  • cors lists 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

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.