Supabase
Verify in Supabase Auth's before-user-created hook. Grant credits by decision.
Keep Supabase Auth and its CAPTCHA. Nolgard runs in the before-user-created hook.
How the token gets there
Supabase Auth hooks don't accept custom request fields. They receive the new user's user_metadata, populated by signUp from options.data. The visitor IP arrives in metadata.ip_address (Before User Created Hook).
- The hook can only allow or reject. Reject on
block. - For other decisions, store the result for your credit grant to read.
- OAuth sign-ups carry no token. Decide what they get.
- The used token stays in
user_metadata. It is single-use and expires in 5 minutes.
Sign-up
await Nolgard.ready;
const token = await Nolgard.execute({ action: 'signup' });
const { error } = await supabase.auth.signUp({
email,
password,
options: { data: { nolgard_token: token } },
});Hosted Before User Created hook
- Open your Nolgard site settings and copy the Supabase Auth hook URL:
https://nolgard.com/v1/hooks/supabase/YOUR_SITE_KEY. - In the Supabase dashboard, open Authentication → Hooks → Before User Created. Choose HTTP, paste the URL, and generate a signing secret.
- Paste the complete
v1,whsec_<base64-secret>into Nolgard’s Hook secret field and save. Enable the hook in Supabase.
The secret is encrypted at rest. Only its last four characters are shown. Requests need valid Standard Webhooks signatures within five minutes. Missing or invalid signatures return HTTP 401.
The sign-up example passes nolgard_token through options.data. With a token, the hook runs normal verification, including rules, actor correlation and email intelligence. Without a token (for example, OAuth), it checks disposable emails, aliases, domain intelligence and repeated network accounts. It records the reasons in your dashboard.
| Decision | Supabase response |
|---|---|
| block | HTTP 403 · Sign-up blocked |
| challenge | HTTP 400 · Additional verification required |
| allow / limit / shadow | HTTP 200 · signup proceeds |
Observe mode always allows. Nolgard storage errors and processing timeouts fail open. A disconnected endpoint cannot send an allow response, so Supabase’s HTTP-hook failure handling still applies. The hosted hook does not grant credits or store a decision in your Supabase database. Use your backend verification response for those actions. See Supabase's request and error contract in Before User Created, and its signature headers and five-second budget in Auth Hooks.
Custom Edge Function alternative: decision table
create table public.signup_decisions (
user_id uuid primary key,
decision text not null,
grant jsonb not null,
created_at timestamptz not null default now()
);
alter table public.signup_decisions enable row level security; -- no policies: server onlyHook: Edge Function
// supabase/functions/before-user-created/index.ts
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0';
import { createClient } from 'https://esm.sh/@supabase/supabase-js';
const wh = new Webhook(Deno.env.get('BEFORE_USER_CREATED_HOOK_SECRET')!.replace('v1,whsec_', ''));
const db = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SECRET_KEY')!);
const reject = (message: string, status: number) =>
new Response(JSON.stringify({ error: { http_code: status, message } }), {
status, headers: { 'Content-Type': 'application/json' },
});
Deno.serve(async (req) => {
let event;
try {
event = wh.verify(await req.text(), Object.fromEntries(req.headers));
} catch {
return new Response('invalid signature', { status: 401 });
}
const { user, metadata } = event;
const token = user.user_metadata?.nolgard_token;
let decision = token ? 'block' : 'allow'; // no token: OAuth or old client
let grant = { share: token ? 0 : 1, credits: null, requires: null };
if (token) {
try {
const response = await fetch('https://nolgard.com/v1/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: Deno.env.get('NOLGARD_SECRET'),
token,
remoteIp: metadata.ip_address,
action: 'signup',
account: { id: user.id, email: user.email },
}),
signal: AbortSignal.timeout(3000),
});
if (response.status >= 500) throw new Error('nolgard 5xx');
const result = await response.json();
if (result.success) { decision = result.decision; grant = result.grant; }
else if (result.error === 'invalid-secret') { // config: fail closed
return reject('Sign-up unavailable, try again.', 503);
}
else if (result.error === 'over-quota') { decision = 'allow'; grant.share = 1; }
} catch {
decision = 'allow'; // timeout or 5xx: fail open
grant = { share: 1, credits: null, requires: null };
}
}
if (decision === 'block') return reject('Sign-up failed.', 403);
await db.from('signup_decisions').upsert({ user_id: user.id, decision, grant });
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});Deploy with --no-verify-jwt and add the function under Authentication → Hooks as an HTTP hook. Set BEFORE_USER_CREATED_HOOK_SECRET and NOLGARD_SECRET as function secrets. HTTP hooks have 5 seconds in total (Auth Hooks). Keep the verify timeout at 3.
Grant credits
-- wherever you create the trial
select coalesce(
(select coalesce((grant->>'credits')::numeric, floor((grant->>'share')::numeric * 100))
from public.signup_decisions where user_id = $1),
100
) as credits;limit gets fewer credits. shadow and challenge get none until you step them up.