feat(auth): require a captcha on the endpoints that send mail (#32)

Signup had no bot protection. The account itself was never the prize — GoTrue
refuses a session until the address is confirmed, and 0 of the 2 unconfirmed
accounts on record ever signed in. The prize is our mail: /signup and /recover
send to whatever address the caller types, so a stranger could make
pezkuwichain.io mail thousands of people who never asked for it. Sending
reputation takes months to rebuild and every product on the domain shares it.

Rate limiting alone was not enough. GoTrue defaults to 30 emails/hour, which is
720 a day to attacker-chosen recipients; tightening it to 15 halves the number
without changing what is possible.

Verification runs inside GoTrue, not here. It POSTs to siteverify with the
widget secret before /signup, /recover, /resend, /magiclink, /otp and /token
with grant_type=password. Adding our own check in the frontend or a proxy would
protect nothing, because /auth/v1/signup is reachable directly.

One shared widget in execute mode rather than one per form: some of these are
buttons with nowhere to put a checkbox, and interaction-only appearance keeps
the challenge invisible unless Cloudflare asks for one. Tokens are single-use,
so each call renders fresh and tears down the previous widget.

Six call sites, matching the six protected endpoints — password login included,
since /token with grant_type=password is enforced. Refresh-token grants are
exempt, so open sessions are unaffected.

Site key travels as a build arg like the WalletConnect id: it is public by
construction, visible in the bundle. Only the secret is privileged and it lives
in GoTrue's env on the auth host.

Ships ahead of enforcement. Turning captcha on server-side before the frontend
sends tokens would break every login and signup in the same instant.
This commit is contained in:
SatoshiQaziMuhammed
2026-08-01 03:45:51 -07:00
committed by GitHub
parent 35884cb8c9
commit c26eeee12c
8 changed files with 153 additions and 2 deletions
+2
View File
@@ -87,6 +87,7 @@ jobs:
VITE_ASSET_HUB_ENDPOINT: wss://asset-hub-rpc.pezkuwichain.io
VITE_PEOPLE_CHAIN_ENDPOINT: wss://people-rpc.pezkuwichain.io
VITE_WALLETCONNECT_PROJECT_ID: 8292a793b7640e8364c378e331e76d04
VITE_TURNSTILE_SITE_KEY: 0x4AAAAAAEDkUkbsUgw2D9zN
- name: Upload build artifact
uses: actions/upload-artifact@v4
@@ -208,6 +209,7 @@ jobs:
VITE_ASSET_HUB_ENDPOINT=wss://asset-hub-rpc.pezkuwichain.io
VITE_PEOPLE_CHAIN_ENDPOINT=wss://people-rpc.pezkuwichain.io
VITE_WALLETCONNECT_PROJECT_ID=8292a793b7640e8364c378e331e76d04
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAEDkUkbsUgw2D9zN
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
cache-from: type=registry,ref=${{ steps.meta.outputs.image }}:cache
+4
View File
@@ -26,6 +26,9 @@ ARG VITE_WS_ENDPOINT_FALLBACK_1=wss://mainnet.pezkuwichain.io
ARG VITE_ASSET_HUB_ENDPOINT=wss://asset-hub-rpc.pezkuwichain.io
ARG VITE_PEOPLE_CHAIN_ENDPOINT=wss://people-rpc.pezkuwichain.io
ARG VITE_WALLETCONNECT_PROJECT_ID=8292a793b7640e8364c378e331e76d04
# Public by design — the site key is visible in the shipped bundle. The
# matching secret lives only in GoTrue's env on the auth host.
ARG VITE_TURNSTILE_SITE_KEY=0x4AAAAAAEDkUkbsUgw2D9zN
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
@@ -35,6 +38,7 @@ ENV VITE_WS_ENDPOINT_FALLBACK_1=$VITE_WS_ENDPOINT_FALLBACK_1
ENV VITE_ASSET_HUB_ENDPOINT=$VITE_ASSET_HUB_ENDPOINT
ENV VITE_PEOPLE_CHAIN_ENDPOINT=$VITE_PEOPLE_CHAIN_ENDPOINT
ENV VITE_WALLETCONNECT_PROJECT_ID=$VITE_WALLETCONNECT_PROJECT_ID
ENV VITE_TURNSTILE_SITE_KEY=$VITE_TURNSTILE_SITE_KEY
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
+8
View File
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback } fr
import { supabase } from '@/lib/supabase';
import { User } from '@supabase/supabase-js';
import { isMobileApp, getNativeWalletAddress, getNativeAccountName } from '@/lib/mobile-bridge';
import { getCaptchaToken } from '@/lib/captcha';
// Session timeout configuration
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
@@ -287,9 +288,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const signIn = async (email: string, password: string, rememberMe: boolean = false) => {
try {
// GoTrue enforces the captcha on /token with grant_type=password, so a
// password login needs a token like signup does. Refresh-token calls are
// exempt, which is why existing sessions keep working on their own.
const captchaToken = await getCaptchaToken();
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
options: { captchaToken },
});
if (!error && data.user) {
@@ -314,10 +320,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const signUp = async (email: string, password: string, username: string, referralCode?: string) => {
try {
const captchaToken = await getCaptchaToken();
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
captchaToken,
data: {
username,
referral_code: referralCode || null,
+126
View File
@@ -0,0 +1,126 @@
/**
* Cloudflare Turnstile token for Supabase Auth calls.
*
* GoTrue verifies these itself — it POSTs to challenges.cloudflare.com with the
* widget secret before /signup, /recover, /resend, /magiclink, /otp and /token
* (grant_type=password). There is no siteverify call in this codebase and there
* should not be: the only way to protect those endpoints is inside the service
* that serves them, since anyone can call /auth/v1/signup directly and skip
* whatever the browser was asked to do.
*
* Why it exists: signup had no bot protection at all. An account is not the
* prize — GoTrue refuses a session until the address is confirmed. The prize is
* our mail: /signup and /recover send to whatever address the caller types, so
* a stranger could make pezkuwichain.io mail thousands of people who never asked.
* That burns the sending domain's reputation, and reputation takes months to
* rebuild.
*
* One shared widget in `execution: "execute"` mode rather than one per form.
* Some of these are buttons, not forms — "resend confirmation" on the dashboard
* has nowhere to put a checkbox — and a challenge that only appears when
* Cloudflare asks for one keeps the common case invisible.
*/
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined;
const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
// A challenge that needs interaction still has to be visible and clickable, so
// this is positioned rather than hidden.
const CONTAINER_ID = 'pezkuwi-turnstile';
interface TurnstileApi {
render(el: HTMLElement, opts: Record<string, unknown>): string;
execute(el: HTMLElement | string, opts?: Record<string, unknown>): void;
reset(id?: string): void;
remove(id: string): void;
}
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
let scriptPromise: Promise<void> | null = null;
let widgetId: string | null = null;
let container: HTMLElement | null = null;
function loadScript(): Promise<void> {
if (window.turnstile) return Promise.resolve();
if (scriptPromise) return scriptPromise;
scriptPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = SCRIPT_URL;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => {
// Let the next attempt retry instead of caching the failure forever.
scriptPromise = null;
reject(new Error('Turnstile script failed to load'));
};
document.head.appendChild(script);
});
return scriptPromise;
}
function ensureContainer(): HTMLElement {
if (container?.isConnected) return container;
container = document.createElement('div');
container.id = CONTAINER_ID;
container.style.position = 'fixed';
container.style.bottom = '1rem';
container.style.right = '1rem';
container.style.zIndex = '2147483647';
document.body.appendChild(container);
return container;
}
/**
* Resolve a fresh Turnstile token, or undefined when no site key is configured.
*
* Undefined rather than throwing: without a key there is nothing to send, and a
* local dev build with captcha disabled server-side should still be able to log
* in. Once GoTrue has captcha enabled it rejects the tokenless request itself,
* which is the check that matters.
*
* Tokens are single-use. Every call resets the widget first, so a retry after a
* failed submit gets a new token instead of being refused as a duplicate.
*/
export async function getCaptchaToken(): Promise<string | undefined> {
if (!SITE_KEY) return undefined;
await loadScript();
const turnstile = window.turnstile;
if (!turnstile) throw new Error('Turnstile unavailable');
const el = ensureContainer();
// Render fresh each time and tear the old one down. Calling render() twice on
// one container would leave two widgets stacked on the page, and reusing a
// widget means its callbacks still close over the previous call's promise.
if (widgetId !== null) {
try {
turnstile.remove(widgetId);
} catch {
// Already gone (page navigation, script reload) — nothing to clean up.
}
widgetId = null;
}
return new Promise<string>((resolve, reject) => {
widgetId = turnstile.render(el, {
sitekey: SITE_KEY,
execution: 'execute',
appearance: 'interaction-only',
action: 'turnstile-spin-v2',
callback: (token: string) => resolve(token),
'error-callback': () => reject(new Error('Captcha failed')),
'timeout-callback': () => reject(new Error('Captcha timed out')),
});
turnstile.execute(el);
});
}
+4 -1
View File
@@ -15,6 +15,7 @@ import { getAllScores, getStakingScoreStatus, startScoreTracking, getPezRewards,
import { getSigner } from '@/lib/get-signer';
import { getKycStatus } from '@pezkuwi/lib/kyc';
import { ReferralDashboard } from '@/components/referral/ReferralDashboard';
import { getCaptchaToken } from '@/lib/captcha';
// Commission proposals card removed - no longer using notary system for KYC approval
// import { CommissionProposalsCard } from '@/components/dashboard/CommissionProposalsCard';
@@ -256,6 +257,7 @@ export default function Dashboard() {
const { error: resendError } = await supabase.auth.resend({
type: 'signup',
email: user.email,
options: { captchaToken: await getCaptchaToken() },
});
if (resendError) {
@@ -272,6 +274,7 @@ export default function Dashboard() {
// This will send an email if the account exists
const { error: resetError } = await supabase.auth.resetPasswordForEmail(user.email, {
redirectTo: `${window.location.origin}/email-verification`,
captchaToken: await getCaptchaToken(),
});
if (resetError) throw resetError;
@@ -1043,4 +1046,4 @@ export default function Dashboard() {
</Tabs>
</div>
);
}
}
+2
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { supabase } from '@/lib/supabase';
import { CheckCircle, XCircle, Loader2, ArrowLeft, Mail, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getCaptchaToken } from '@/lib/captcha';
export default function EmailVerification() {
const [searchParams] = useSearchParams();
@@ -42,6 +43,7 @@ export default function EmailVerification() {
const { error } = await supabase.auth.resend({
type: 'signup',
email: email,
options: { captchaToken: await getCaptchaToken() },
});
if (error) throw error;
+5 -1
View File
@@ -8,6 +8,7 @@ import { supabase } from '@/lib/supabase';
import { useToast } from '@/hooks/use-toast';
import { Loader2, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getCaptchaToken } from '@/lib/captcha';
export default function PasswordReset() {
const navigate = useNavigate();
@@ -35,7 +36,10 @@ export default function PasswordReset() {
setLoading(true);
try {
const redirectTo = `${window.location.origin}/reset-password`;
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { redirectTo });
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
redirectTo,
captchaToken: await getCaptchaToken(),
});
if (error) throw error;
// Generic success message regardless of whether the email exists
+2
View File
@@ -9,6 +9,7 @@ import { supabase } from '@/lib/supabase';
import { identityToUUID } from '@shared/lib/identity';
import { Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getCaptchaToken } from '@/lib/captcha';
type Status = 'loading' | 'connecting' | 'success' | 'error';
@@ -111,6 +112,7 @@ export default function TelegramConnect() {
const { error: signInError } = await supabase.auth.signInWithOtp({
email: telegramEmail,
options: {
captchaToken: await getCaptchaToken(),
shouldCreateUser: true,
data: {
telegram_id: parseInt(telegramId, 10),