Firebase Authentication makes creating an account almost free, which is exactly the problem when you hand out credits, trials or referral bonuses per account. One person with a browser and a list of email addresses can open dozens of accounts, and nothing in the default Auth flow notices that they all come from the same laptop.
Blocking functions give you a hook at the right moment. beforeUserCreated runs after Firebase has validated the credentials but before the user record is saved. If it throws, the account never exists. This guide wires a Prynt device check into that hook, then links the new uid back to the device so the next signup from the same machine sees it.
The shape of the flow
Blocking functions require Firebase Authentication with Identity Platform, and they receive the user being created, not your signup form. That shapes everything:
- Browser: run
identify()on the signup page to get arequestId. - Pre-signup callable: send the
requestIdand email to a callable function that stores them, keyed by the normalized email. beforeUserCreated: look up the storedrequestId, fetch the event from the Prynt API with your secret key, and throwHttpsErrorif the device is over your limit.- After signup: a second callable reads the
requestIdfrom the user’s custom claims and attaches theuidwithPUT /v1/events/{requestId}.
Step 4 is what makes the whole thing work. The accountsOnDevice count in the event only includes accounts you have linked. If you skip linking, every device looks brand new forever. The accountsOnDevice deep dive covers the field in detail.
Browser: identify, register intent, sign up
Load the agent from the CDN (or your first-party proxy) and identify right before you create the user:
import { getAuth, createUserWithEmailAndPassword } from 'firebase/auth';
import { getFunctions, httpsCallable } from 'firebase/functions';
const auth = getAuth();
const functions = getFunctions();
const prynt = await Prynt.load({ apiKey: 'pk_live_…' }); // global from prynt.umd.js
async function signUp(email, password) {
const { requestId } = await prynt.identify({ tag: { action: 'signup' } });
await httpsCallable(functions, 'registerSignupIntent')({ requestId, email });
await createUserWithEmailAndPassword(auth, email, password);
await httpsCallable(functions, 'linkPryntDevice')();
}
The public key only identifies. Every decision happens server side with the sk_… secret key, which never reaches the browser.
Pre-signup callable: store the requestId
The callable does no scoring. It just parks the requestId where the blocking function can find it:
// functions/index.js
const { onCall, HttpsError: CallError } = require('firebase-functions/v2/https');
const { beforeUserCreated, HttpsError } = require('firebase-functions/v2/identity');
const { defineSecret } = require('firebase-functions/params');
const { initializeApp } = require('firebase-admin/app');
const { getFirestore, FieldValue } = require('firebase-admin/firestore');
const crypto = require('node:crypto');
initializeApp();
const db = getFirestore();
const PRYNT_SECRET = defineSecret('PRYNT_SECRET_KEY');
const MAX_OTHER_ACCOUNTS = 1; // one account per device; raise for shared-device audiences
const emailKey = (e) =>
crypto.createHash('sha256').update(String(e).trim().toLowerCase()).digest('hex');
exports.registerSignupIntent = onCall(async (req) => {
const { requestId, email } = req.data || {};
if (typeof requestId !== 'string' || !/^[A-Za-z0-9_-]{4,100}$/.test(requestId)) {
throw new CallError('invalid-argument', 'Bad requestId');
}
await db.collection('signupIntents').doc(emailKey(email)).set({
requestId, createdAt: FieldValue.serverTimestamp(),
});
return { ok: true };
});
Hashing the email keeps raw addresses out of this collection. Add a TTL policy on createdAt so stale intents clean themselves up.
The blocking function
Now the check itself. Fetch the event, count the other accounts on the device, and decide:
async function getEvent(requestId, secretKey) {
const res = await fetch(`https://api.pryntid.com/v1/events/${encodeURIComponent(requestId)}`, {
headers: { Authorization: `Bearer ${secretKey}` },
signal: AbortSignal.timeout(2500),
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Prynt ${res.status}`);
return res.json();
}
exports.beforecreated = beforeUserCreated({ secrets: [PRYNT_SECRET] }, async (event) => {
const user = event.data;
if (!user.email) return; // phone or anonymous signups: key the intent differently
const snap = await db.collection('signupIntents').doc(emailKey(user.email)).get();
if (!snap.exists) return { customClaims: { prynt: 'missing' } };
const { requestId } = snap.data();
let ev;
try {
ev = await getEvent(requestId, PRYNT_SECRET.value());
} catch (err) {
return { customClaims: { prynt: 'unchecked', pryntRequestId: requestId } }; // fail open
}
if (!ev) return { customClaims: { prynt: 'missing' } };
// A requestId already attached to someone else is a replay.
if (ev.linkedId && ev.linkedId !== user.uid) {
throw new HttpsError('permission-denied', 'Please reload the page and try again.');
}
const aod = ev.accountsOnDevice || { count: 0, accounts: [] };
const others = aod.accounts.filter((a) => a.linkedId !== user.uid).length;
if (ev.decision === 'block' || aod.truncated || others >= MAX_OTHER_ACCOUNTS) {
throw new HttpsError('permission-denied',
"We couldn't create an account from this device. If this is a mistake, contact support.");
}
return {
customClaims: {
prynt: ev.decision === 'challenge' ? 'flag' : 'ok',
pryntRequestId: requestId,
},
};
});
A few decisions in there are worth calling out.
Missing is not guilty. A visitor with Global Privacy Control enabled gets no stored event by default, and script blockers stop the agent entirely. Treat a missing requestId as “flag,” not “block,” unless your abuse level justifies turning “no JavaScript” into “no account.”
Fail open on errors. Firebase gives blocking functions a short deadline, so the lookup gets a hard timeout. If Prynt is unreachable, let the user through with a prynt: 'unchecked' claim and re-check later.
challenge becomes a flag. The account is created, but the claim lets your app hold back credits until the user verifies a phone number or card. Degraded free tiers explains why that beats a hard block for borderline cases.
Link the uid after the user exists
Custom claims returned from beforeUserCreated end up in the user’s ID token, so the post-signup callable can read the requestId without trusting anything the client sends:
exports.linkPryntDevice = onCall({ secrets: [PRYNT_SECRET] }, async (req) => {
if (!req.auth) throw new CallError('unauthenticated', 'Sign in first');
const requestId = req.auth.token.pryntRequestId;
if (!requestId) return { linked: false };
await fetch(`https://api.pryntid.com/v1/events/${encodeURIComponent(requestId)}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${PRYNT_SECRET.value()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ linkedId: req.auth.uid }),
});
return { linked: true };
});
You could call the same PUT inside the blocking function, since event.data.uid is already assigned. The catch is that the user hasn’t been committed yet. If creation fails afterwards, a uid that never existed is counted against the device and can block that person’s real second attempt.
The client might also never call linkPryntDevice, because the tab closed mid-signup. Cover that by passing the user’s id on later identifications. prynt.identify({ linkedId: user.uid }) on app load attaches the account to whatever device it shows up on, which also builds the device history you need for ban-evasion checks.
Choosing the limit
MAX_OTHER_ACCOUNTS = 1 means one account per device, which is right for a generous free tier on a consumer product. B2B tools used on shared office machines, or education apps on family tablets, usually want 2 or 3, with the extra signups flagged instead of refused. The device-based signup limits guide walks through picking that number, and multi-accounting detection covers the patterns that slip past a pure count. For the checks that belong next to this one, such as bot and disposable-email signals, see protecting signup from fraud.
Before you enforce anything, deploy the blocking function in a log-only mode. Return the claims, never throw, and look at who would have been refused for a week. Then turn on the HttpsError.
Phone, anonymous and federated signups
The intent store above is keyed by email because that is what event.data reliably carries for password signups. Other providers need a different key. For phone auth, hash event.data.phoneNumber the same way and have the pre-signup callable store the requestId under that hash. For Google or Apple sign-in, the email arrives in event.data.email too, so the same lookup works as long as the browser registers intent before calling signInWithPopup. Anonymous users have nothing stable to key on; leave them unchecked and run the device check when they upgrade to a real account, since that is the moment credits or trials usually unlock.
Testing it
The Firebase emulator suite runs blocking functions locally. Point getEvent at a mock endpoint, or use a test-environment secret key, since a key only sees its own environment’s events. Sign up twice from the same browser profile, then again in an incognito window: all three should land on the same visitorId, and the second or third should be refused depending on your limit. The playground is a quick way to confirm the visitorId stays stable before you debug the function.
Once it holds up in the emulator, ship it in log-only mode, read a week of claims, and only then make it throw.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.