Docs / Guides / Supabase Auth
Supabase Auth signup CAPTCHA guide
The key rule is simple: do not create the account first and ask questions later. Verify the human before you call supabase.auth.signUp so fake signups never become real users in your auth system.
When this guide fits
Use this guide if Supabase Auth owns your signup flow and you want Playtcha to stop fake account creation before it burns email volume, free-tier credits, or dashboard trust. The most common stack here is Next.js App Router plus Server Actions, but the underlying rule is the same in any server framework.
If you want the full long-form walkthrough and product context, read the Supabase Learn guide. This docs page is the implementation-first version.
1. Set the right keys
NEXT_PUBLIC_PLAYTCHA_SITE_KEY=pk_live_REPLACE_ME
PLAYTCHA_SECRET=sk_live_REPLACE_ME
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...- The public Playtcha site key is safe in the browser.
- The Playtcha secret stays server-side only.
- Configure your allowed domains so preview or localhost behavior is explicit, not accidental.
- For Supabase + Next.js App Router, use
@supabase/ssrso auth cookies flow correctly across server boundaries. Do not use@supabase/supabase-jsdirectly from a Server Action.
2. Verify-dry, then signUp, then verify
The naive pattern (redeem the token, then call signUp) burns the user’s captcha when signUp fails for an unrelated reason — duplicate email, weak password, or a downstream provider hiccup. The user has to play a fresh challenge before they can retry. That is a UX bug.
The atomic pattern is three calls: POST /v1/verify-dry to confirm the token is valid without redeeming it, then supabase.auth.signUp, then POST /v1/verify only after signUp returns success. /v1/verify-dry returns { would_succeed: true } on success and the same 4xx envelopes /v1/verify uses on failure.
// app/signup/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
const failClosed = process.env.PLAYTCHA_FAIL_CLOSED === "1";
type State = { error: string | null };
async function postVerify(path: "/v1/verify" | "/v1/verify-dry", token: string) {
return fetch("https://playtcha.com" + path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: process.env.PLAYTCHA_SECRET, token }),
signal: AbortSignal.timeout(5000),
});
}
export async function signupAction(_prev: State, formData: FormData): Promise<State> {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const token = String(formData.get("playtcha-token") ?? "");
if (!token) return { error: "captcha_missing" };
// Phase 1: dry-run verify. Does not redeem, does not bump usage.
try {
const res = await postVerify("/v1/verify-dry", token);
const data = await res.json().catch(() => null);
if (res.status >= 500 || !data) {
console.warn("playtcha.verify-dry.unavailable", { status: res.status });
if (failClosed) return { error: "verifier_unavailable" };
// fall through — treat as verified
} else if (!data.would_succeed) {
// 4xx: token_invalid, token_expired, already_redeemed, bad_secret, etc.
return { error: "captcha_failed" };
}
} catch (err) {
console.warn("playtcha.verify-dry.unreachable", { err: String(err) });
if (failClosed) return { error: "verifier_unavailable" };
}
// Phase 2: real signUp. If this fails, the token is still spendable.
const cookieStore = cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get: (name) => cookieStore.get(name)?.value,
set: (name, value, options) => cookieStore.set({ name, value, ...options }),
remove: (name, options) => cookieStore.set({ name, value: "", ...options }),
},
},
);
const { error: signupErr } = await supabase.auth.signUp({ email, password });
if (signupErr) {
// Don't burn the captcha for a duplicate-email or weak-password error.
return { error: signupErr.message };
}
// Phase 3: redeem the captcha now that the account is created.
// Fire-and-forget in fail-open mode: if redeem fails, the account still exists.
try {
const res = await postVerify("/v1/verify", token);
const data = await res.json().catch(() => null);
if (data?.degraded) console.info("playtcha.verify.degraded", { reason: data.degraded_reason });
} catch (err) {
console.warn("playtcha.verify.redeem_failed", { err: String(err) });
}
redirect("/welcome");
}The verify call belongs ahead of confirmation emails, ahead of expensive provisioning, and ahead of any third-party API call you cannot easily undo. The dry-run is what makes the flow safely retriable on the user’s side.
Wire the action state into the form
Server Actions return values are only useful if the page surfaces them. Use useFormState in a Client Component so the { error } return value renders next to the form. Without this, your action quietly fails and the user sees a stale form.
// app/signup/page.tsx
"use client";
import { useFormState } from "react-dom";
import { signupAction } from "./actions";
const initial = { error: null as string | null };
export default function SignupPage() {
const [state, formAction] = useFormState(signupAction, initial);
return (
<form action={formAction}>
<input name="email" type="email" required autoComplete="email" />
<input name="password" type="password" required autoComplete="new-password" />
<div
className="playtcha"
data-sitekey={process.env.NEXT_PUBLIC_PLAYTCHA_SITE_KEY!}
></div>
<button type="submit">Create account</button>
{state.error ? <p role="alert">Signup failed: {state.error}</p> : null}
</form>
);
}3. Decide where login needs friction
Signup almost always benefits from a clear human gate if abuse is real. Login is different. Existing users should not be punished on every sign-in by default. That is why many teams keep Playtcha on signup first, then add it to suspicious login paths later.
- Always-on on signup is reasonable.
- Risk-based or conditional on login is usually better.
- Password reset and account recovery often justify an explicit check.
For the broader product trade, read the login-form guide.
4. Abuse checklist around Auth
| Layer | Why it still matters |
|---|---|
| Email confirmation | Stops some fake-account value even if signup gets through. |
| Rate limiting | CAPTCHA is not a replacement for endpoint throttling. |
| RLS / app authz | A verified signup should still not get more data than it should. |
| Monitoring | Watch signup failures and retries so you notice abuse shifts early. |
FAQ
Why dry-run instead of just verify?
Because /v1/verify is single-use. If you redeem before signUp and signUp fails (duplicate email, weak password, network blip), the token is gone and the user has to play another challenge before they can retry. The dry-run holds the receipt; the real verify only fires after the account exists.
Can I verify after signUp instead?
You can, but that defeats much of the point. The abuse has already reached Auth, and you may already have fired side effects. Dry-run before signUp, real verify after, is the pattern that balances both concerns.
Does this replace Supabase rate limits?
No. CAPTCHA raises the floor for bot abuse; it does not replace endpoint throttling or sane auth controls.
What about mobile?
The widget is a minigame. Reserve at least 240px of vertical space in the signup card on small phones, and the games respect prefers-reduced-motion. Test the layout in a 360×640 viewport before shipping.
What should I read next?
Start at quickstart if you have not implemented the base flow yet, then read the signup guide for the product-side tradeoffs. The token lifecycle reference covers why dry-run + verify is two calls instead of one.