Firebase blocks properly.
It just will not carry your data.
beforeUserCreated is a real synchronous gate: throw and the user record is never written and the client's call rejects. Two things make it harder than it looks. It receives no custom data from the client at all, and the whole function has seven seconds to answer.
01Stash the signals first
A blocking function gets locale, ipAddress, userAgent, eventType, the user record being created and, for identity provider sign-ins, whichever credential fields you enabled in the console. There is no unsafe metadata, no custom header, no extra argument. So the page hands the signals to a callable before it touches auth.
// The client calls this BEFORE createUserWithEmailAndPassword()
// or signInWithPopup().
import { httpsCallable } from "firebase/functions";
const features = window.Huma.debug().features;
await httpsCallable(functions, "stashSignals")({ email, features });
await createUserWithEmailAndPassword(auth, email, password);// functions/src/index.ts — firebase-functions v6, 2nd gen
import { beforeUserCreated, HttpsError } from "firebase-functions/v2/identity";
import { onCall } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
import { initializeApp } from "firebase-admin/app";
import { getFirestore } from "firebase-admin/firestore";
import { createHash } from "node:crypto";
initializeApp();
const db = getFirestore();
const HUMA_API_KEY = defineSecret("HUMA_API_KEY");
const TTL_MS = 120_000;
const emailKey = (e: string) => `e:${e.trim().toLowerCase()}`;
// Fallback join key for popup flows, where the browser does not know the
// email in advance. Best effort: the IP the callable sees and the one
// Identity Platform reports are not guaranteed to match.
const fpKey = (ip?: string | null, ua?: string | null) =>
`f:${createHash("sha256").update(`${ip ?? ""}|${ua ?? ""}`).digest("hex")}`;
export const stashSignals = onCall(async (req) => {
const { email, features } = req.data as { email?: string; features?: unknown };
if (!features) throw new HttpsError("invalid-argument", "missing features");
const ip = req.rawRequest.ip ?? null;
const ua = req.rawRequest.get("user-agent") ?? null;
const doc = { features, expires: Date.now() + TTL_MS };
const keys = [fpKey(ip, ua), ...(email ? [emailKey(email)] : [])];
await Promise.all(
keys.map((k) => db.collection("huma_signals").doc(k).set(doc)),
);
return { ok: true };
});02The gate
// The gate itself.
export const gateSignup = beforeUserCreated(
{ secrets: [HUMA_API_KEY] },
async (event) => {
const user = event.data;
const keys = [
...(user?.email ? [emailKey(user.email)] : []),
fpKey(event.ipAddress, event.userAgent),
];
let features: unknown = null;
for (const k of keys) {
const snap = await db.collection("huma_signals").doc(k).get();
const d = snap.data();
if (d && d.expires > Date.now()) { features = d.features; break; }
}
// Anonymous and custom-token paths never stash anything. Allow.
if (!features) return;
let v;
try {
const res = await fetch("https://humaverify.com/api/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${HUMA_API_KEY.value()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: user?.uid ?? "firebase",
sessionData: features,
}),
// The WHOLE function has 7 seconds. Leave room for a cold start.
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return; // fail open
v = await res.json();
} catch {
return; // fail open
}
if (v.agent?.is_agent || v.human === false) {
throw new HttpsError("permission-denied", "unverified_session");
}
},
);What bites
- Blocking functions require upgrading the project to Firebase Authentication with Identity Platform. That upgrade has its own pricing.
- Seven seconds for the whole function. A cold start plus a Firestore read plus an outbound call can reach it, so bound the call and keep the function warm if signups matter to you.
- Rejections reach the client as
auth/internal-errorwith your message inside aBLOCKING_FUNCTION_ERROR_RESPONSEstring. Do not build user-facing copy on it. - Anonymous auth and custom token auth do not trigger blocking functions at all. An app that signs users in anonymously and upgrades later never passes through this gate.
- Identity Platform registers one function per trigger type. If the project already uses
beforeUserCreatedfor something else, this has to live inside that same function. - A missing stash is not evidence of a bot. It is an anonymous upgrade, a custom token, a popup whose keys did not line up, or a slow network. Allow.
Why this instead of a CAPTCHA
A CAPTCHA charges every legitimate user for the existence of bots that increasingly solve puzzles anyway. This charges nobody: the person types, and the decision happens on your server from what already happened. It also answers a question a CAPTCHA does not, which is whether the session was an AI agent driving a real browser rather than a script. What it does not catch is written down at /limits, before you build on it.
Firebase for auth, useHUMA for humanity.
Free plan: 1,000 verifications a month · no card · no expiry · AI-agent verdict included.
Get your API key →Function names, signatures and the seven second deadline on this page were checked against Firebase's live documentation on 26 Aug 2026. If something has drifted, tell us at team@humaverify.com and it gets fixed.