Supabase makes signup a single call: supabase.auth.signUp({ email, password }). That convenience is exactly what trial farmers like. Every fresh email address becomes a fresh account with fresh credits, and nothing in Auth knows that the last five signups came from the same laptop.
The Before User Created auth hook fixes the timing problem. It runs before the auth.users row is written, so a refused signup never becomes a user. Feed it a device identity and you can enforce “one trial per device” at the one place every email/password signup must pass through.
This guide walks through the Supabase recipe in the Prynt repo (integrations/supabase): what each piece does, why it’s split in two, and what it deliberately leaves alone.
The shape of the flow
There are three moving parts:
- The signup page runs
identify()and passes therequestIdthroughoptions.data. - The
before-user-createdEdge Function receives the hook, fetches the event with your secret key, and returns{}to allow or a 403 to refuse. - The
prynt-linkEdge Function, called by anAFTER INSERTtrigger onauth.usersviapg_net, attaches the new user id to the device and stores the verdict.
The split exists because the hook can only accept or reject. It cannot write metadata, and Supabase notes that it can fire for a user that then fails to insert. Linking an account that might never exist would pollute the device’s history, so linking waits until the row is committed.
Step 1: pass the requestId with the signup
Identify on page load, then hand the id to Supabase as user metadata:
const ready = Prynt.load({ apiKey: 'pk_live_…' })
.then((agent) => agent.identify({ tag: { action: 'signup' } }))
.catch(() => null); // never block the form client-side; the hook decides
form.addEventListener('submit', async (e) => {
e.preventDefault();
const ident = await ready;
const { data, error } = await supabase.auth.signUp({
email, password,
options: { data: { prynt_request_id: ident?.requestId ?? null } },
});
// refused → error.message is the hook's message
});
options.data arrives in the hook payload as user.user_metadata.prynt_request_id. The browser result is only a pointer here; nothing on the client decides whether the account is allowed.
Step 2: verify inside the hook
Supabase signs hook calls with Standard Webhooks, so the function verifies the signature first and then runs the policy:
import { Webhook } from 'https://esm.sh/[email protected]';
import { beforeUserCreated, pryntEnv } from '../_shared/prynt-supabase.mjs';
const hookSecret = (Deno.env.get('BEFORE_USER_CREATED_HOOK_SECRET') ?? '').replace('v1,whsec_', '');
Deno.serve(async (req) => {
const raw = await req.text();
let payload;
try {
payload = new Webhook(hookSecret).verify(raw, Object.fromEntries(req.headers));
} catch {
return Response.json({ error: { http_code: 401, message: 'invalid hook signature' } }, { status: 401 });
}
const { status, body } = await beforeUserCreated(payload, pryntEnv(Deno.env.toObject()));
return Response.json(body, { status });
});
Under the hood, beforeUserCreated calls GET /v1/events/{requestId} with your sk_… key and evaluates two things: Prynt’s own decision, and accountsOnDevice.count, the number of your accounts already seen on that device. When the verdict is block, it returns a 403 with { error: { http_code: 403, message } }, and Supabase shows that message as the signUp error.
Deploy it without JWT verification, because Auth calls it with a webhook signature rather than a user token:
supabase functions deploy before-user-created --no-verify-jwt
supabase functions deploy prynt-link --no-verify-jwt
supabase secrets set --env-file supabase/functions/.env
Then point Authentication → Hooks → Before User Created at the function URL and put the generated v1,whsec_… secret in BEFORE_USER_CREATED_HOOK_SECRET.
Step 3: link the user after commit
The migration adds a trigger that fires once the user row exists and posts the user id and request id to prynt-link:
perform net.http_post(
url := link_url,
headers := jsonb_build_object('Content-Type', 'application/json', 'x-prynt-link-secret', link_secret),
body := jsonb_build_object(
'user_id', new.id,
'request_id', new.raw_user_meta_data ->> 'prynt_request_id'
),
timeout_milliseconds := 5000
);
pg_net only sends after the transaction commits and never fails the signup. The URL and shared secret live in Vault, not in the migration.
prynt-link does three things. It re-runs the same policy with the new user excluded from its own count. It writes the verdict to app_metadata.prynt. And it calls PUT /v1/events/{requestId} with { "linkedId": "<user id>" }, so the next signup from that device sees this account in accountsOnDevice. That last call is what makes the limit work at all: without linking, every device looks empty.
The re-check also catches races. Two signups fired in parallel from one device can both pass the hook before either is linked; the after-insert step sees both and bans the one that tipped over the limit.
Tuning the policy
Everything is configured with environment variables shared by every Prynt signup recipe:
| Variable | Default | Meaning |
|---|---|---|
PRYNT_MAX_ACCOUNTS_PER_DEVICE | 2 | Trips when the device already has this many other accounts |
PRYNT_ON_LIMIT | block | Action at the limit |
PRYNT_ON_PRYNT_BLOCK | block | Prynt’s own decision is block |
PRYNT_ON_CHALLENGE | flag | Prynt’s decision is challenge |
PRYNT_ON_MISSING | flag | No requestId (OAuth, SSO, script blockers) |
PRYNT_ON_ERROR | allow | Prynt unreachable: fail open |
Each action is one of allow, flag, verify or block. If every extra account costs you real money in credits or compute, block at the limit is reasonable. If shared family computers are common in your audience, verify is gentler: the account is created with app_metadata.prynt.action = 'verify', and your app asks for a phone OTP before credits unlock. Because app_metadata lands in the JWT, RLS policies can read it directly:
(auth.jwt() -> 'app_metadata' -> 'prynt' ->> 'action') is distinct from 'verify'
Start with flag if you just want to measure. Query auth.users for raw_app_meta_data -> 'prynt' ->> 'action' = 'flag' after a week and you’ll know how much of your signup volume is repeat devices before you refuse anyone. The device-based signup limits guide covers how to pick the number.
What the hook doesn’t cover
Be honest about the gaps so they don’t surprise you:
- OAuth, SSO, anonymous and OTP signups carry no
options.data. The hook sees no requestId and appliesPRYNT_ON_MISSING. Leave it atflagunless every signup path goes through your own form, or you’ll refuse every Google signup. auth.admin.createUserdoesn’t trigger the hook at all. The after-insert step still links and flags those users.- Bans are long, not permanent. A blocked account gets
ban_duration: '876000h'; lift it withauth.admin.updateUserById(id, { ban_duration: 'none' })when support confirms a false positive.
For the broader pattern behind this recipe, see protecting signup from fraud and multi-accounting detection. The API fields are in the docs, and the Free plan’s 20k identifications a month on the pricing page is enough to run this on a real project. Copy the functions, apply the migration, set PRYNT_ON_LIMIT=flag for the first week, and read the flagged list before you turn on blocking.
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.