Guide · Firebase Authentication

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 };
});
Two keys on purpose. Email works for the password flow. For a popup the browser does not know the email in advance, so the fallback joins on a hash of IP and user agent. That is best effort: the IP the callable observes and the one Identity Platform reports are not guaranteed to be the same, and the code allows rather than blocks when they do not line up.

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

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.

Next.jsWordPressClerkAuth0Supabase AuthAuth.js / NextAuth

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.

Help