gaste.co signs people in with e-mail OTP, Google and Apple. No passwords anywhere. Some weeks ago junk accounts started appearing in Supabase, and because Supabase sends its auth mail through my SES account, each one of them was a real e-mail delivered to an address that had never asked for anything.
If you landed here from a search, the short version: a script calling /auth/v1/otp with your public anon key makes Supabase create the user and send the code, so every call costs you one send. Your own frontend cannot stop it and CAPTCHA costs you a mobile release. What works without touching your clients is a Before User Created hook, because GoTrue calls it before the insert and before the mail.
Why this costs you something
The bots do not need to get in. Creating the account is not the payload; the mail is.
signInWithOtp with shouldCreateUser: true is the default flow in every Supabase example. For an address GoTrue has never seen, it creates the user and mails a code. Point a loop at that endpoint with a list of addresses and Supabase sends to all of them, from your domain, with your reputation attached.
If you are on Supabase's built-in SMTP this is throttled hard and mostly just ugly. If you configured custom SMTP, which everyone does eventually, the bill lands on your own sender. On SES, bounce rate and complaint rate are account-level. Cross the thresholds and AWS pauses sending for the entire account, not for the one app that caused it. My account also sends transactional mail for other projects and a daily newsletter, so a bad enough run would have taken all of it down together.
That is the actual risk. Not spam rows in a table.
The honeypot does not work
My first instinct was the cheapest thing: a hidden field in the login form, reject the submit if it is filled.
It guards nothing. signInWithOtp runs in the browser and posts straight to https://<project>.supabase.co/auth/v1/otp. My form is not in that path, and neither is my server. A bot with your anon key, which is public by design and sitting in your JS bundle, never has to load your page at all.
I checked whether these particular bots were driving a headless browser against my login page instead. They were not. Pageviews on /login stayed flat across the whole period, with no bump on the days accounts appeared and nothing at all on the day of the largest burst. Whatever was calling the endpoint never loaded the page.
A honeypot only works when your server is in the request path. For Supabase auth, it is not.
CAPTCHA works, but read the routing first
Supabase's project-level CAPTCHA (hCaptcha or Cloudflare Turnstile) is the supported answer and it does stop this. Before you reach for it, know which routes it actually gates, because the docs do not say. From internal/api/api.go:
r.With(api.verifyCaptcha).Route("/signup", ...)
r.With(api.verifyCaptcha).Post("/magiclink", api.MagicLink)
r.With(api.verifyCaptcha).Post("/otp", api.Otp)
r.With(api.verifyCaptcha).Post("/token", api.Token)
r.With(api.verifyCaptcha).Post("/recover", api.Recover)
r.With(api.verifyCaptcha).Post("/resend", api.Resend)
And /token is not uniformly gated. isIgnoreCaptchaRoute in middleware.go exempts three grant types:
case "pkce": return true
case "refresh_token": return true
case "id_token": return true
case "password": return false
So web OAuth (which finishes as grant_type=pkce) and native mobile Google/Apple (signInWithIdToken, which is grant_type=id_token) keep working untouched. Only the e-mail paths need a token.
The catch is that it is a project-wide switch with no client-version awareness. The moment you flip it, every already-installed build of your mobile app that calls signInWithOtp starts failing with captcha protection: request disallowed (no captcha_token found). You cannot ship the client fix first and enable it later for old versions only. For a store-distributed app that means: ship the release, wait for adoption, then flip. Worth knowing that Turnstile has no official React Native SDK and hCaptcha does, if your app keeps e-mail OTP.
I wanted something live the same day.
The Before User Created hook
This hook fires for every account Supabase is about to create, receives the pending user and the caller's IP, and can reject. Three properties make it the right tool here, all of them visible in the source rather than the docs.
It runs before the mail. In internal/api/signup.go:
if err := a.triggerBeforeUserCreated(r, db, signupUser); err != nil { // 186
return err
}
err = db.Transaction(func(tx *storage.Connection) error {
user, terr = a.signupNewUser(tx, signupUser) // 199
...
if terr = a.sendConfirmation(r, tx, user, flowType); terr != nil { // 250
Reject at 186 and there is no row and no send.
It covers the OTP path. MagicLink marshals a SignupParams and calls a.Signup internally for new addresses, so the hook sits on the OTP flow even though nothing called /signup.
Your bookkeeping survives a rejection. The Postgres hook runs in its own transaction, which commits, and only then does GoTrue inspect the returned payload for an error (internal/hooks/hookspgfunc/hookspgfunc.go). So a table of attempts written inside the hook persists even for calls you reject, and a caller that keeps hammering stays in the penalty box instead of resetting its own counter.
The function is unremarkable once you know all that. Log the attempt, then decide:
CREATE OR REPLACE FUNCTION public.before_user_created(event jsonb)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
DECLARE
v_ip inet := (event -> 'metadata' ->> 'ip_address')::inet;
v_email text := lower(event -> 'user' ->> 'email');
v_count int;
BEGIN
-- OAuth sends no mail of ours, so it is none of our business.
IF event -> 'user' -> 'app_metadata' ->> 'provider' IS DISTINCT FROM 'email' THEN
RETURN '{}'::jsonb;
END IF;
INSERT INTO public.signup_attempts (ip, email) VALUES (v_ip, v_email);
SELECT count(*) INTO v_count FROM public.signup_attempts
WHERE ip = v_ip AND created_at > now() - interval '1 hour';
IF v_count > 3 THEN
RETURN jsonb_build_object('error', jsonb_build_object(
'message', 'Too many attempts. Try again later, or sign in with Google.',
'http_code', 429));
END IF;
RETURN '{}'::jsonb;
END;
$$;
GRANT EXECUTE ON FUNCTION public.before_user_created(jsonb) TO supabase_auth_admin;
REVOKE EXECUTE ON FUNCTION public.before_user_created(jsonb) FROM anon, authenticated, public;
Mine adds a daily per-IP limit, a global hourly breaker for when the caller rotates addresses, and a list of domains that only ever appear as typos of real ones (gamil.com, gnail.com). Wire it up under Authentication → Hooks → Before User Created → Postgres function. Postgres hooks get 2 seconds; counting a few hundred rows does not come close.
Note the OAuth bail-out at the top. The hook fires for Google and Apple sign-ups too, and those send no mail of yours, so let them through before you spend any budget on them.
Test it on a throwaway cluster, not on your project
There is no dry-run for an auth hook, and iterating on it by attempting real sign-ups is how you mail more strangers. Run it locally instead. initdb a scratch cluster, create the supabase_auth_admin, anon and authenticated roles so the grants parse, load your file, and call the function with a hand-built event:
CREATE FUNCTION t(email text, ip text, provider text DEFAULT 'email') RETURNS text
LANGUAGE sql AS $$
SELECT coalesce(public.before_user_created(jsonb_build_object(
'metadata', jsonb_build_object('ip_address', ip),
'user', jsonb_build_object('email', email,
'app_metadata', jsonb_build_object('provider', provider))
)) -> 'error' ->> 'http_code', 'allow');
$$;
SELECT g, t('u' || g || '@gmail.com', '2.2.2.2') FROM generate_series(1, 5) g;
Five seconds per iteration, and you can shift created_at backwards to test the windows aging out instead of waiting an hour. Mine caught a malformed-IP crash and an off-by-one on the hourly limit before anything went near production.
What it does not cover
The hook only fires for addresses Supabase has never created. An account a bot made yesterday is not new any more, so its next OTP request skips Signup entirely, the hook never sees it, and the mail goes out. Clear the backlog or you have fixed only half of it:
DELETE FROM auth.users
WHERE email_confirmed_at IS NULL
AND last_sign_in_at IS NULL
AND raw_app_meta_data ->> 'provider' = 'email'
AND created_at < now() - interval '1 hour';
Deleting these costs a real person nothing. Someone who asked for a code and never used it gets created again on their next attempt, through the hook this time.
Takeaways
- The damage from sign-up spam is the mail, not the rows. Reason about it as a sender-reputation problem and the right layer becomes obvious.
- Your anon key is public, so any defence that lives in your own frontend or backend is not in the request path. Supabase's auth endpoints are reachable without you.
- CAPTCHA is a project-wide switch, which makes it a release-coordination problem for mobile, not a config change.
- Check what your metrics actually measure before sizing anything from them. I spent an hour reading SES statistics as if they were this app's, on an account that sends for several projects. They were unattributable, and my first set of thresholds was guesswork dressed up as measurement.