Docs/Sicurezza
// sicurezza
Rate limiting: magic link
Il magic link è comodissimo e ha un difetto strutturale: chiunque conosca l'indirizzo email di un altro può far partire email a nome tuo. Non ruba niente, ma ti brucia quota di invio e ti fa segnalare come spam. Vediamo cosa fa il repo oggi, cosa fa Supabase per te, e cosa devi aggiungere.
Cosa fa oggi il repo
La richiesta parte da app/signin/page.tsx, lato client:
} else if (type === "magic_link") {
await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: redirectURL,
},
});
toast.success("Controlla la tua email!");
setIsDisabled(true);
}Due cose da notare, entrambe importanti:
setIsDisabled(true)è solo stato React. Disabilita il bottone finché non ricarichi la pagina. Non è una protezione: chi vuole abusarne chiama l'endpoint Supabase direttamente, il browser non c'entra.- Il risultato di
signInWithOtp()non viene letto. Supabase non lancia: restituisce{ data, error }. Quindi se sei sotto rate limit e l'email non parte, l'utente vede lo stesso “Controlla la tua email!” e resta ad aspettare qualcosa che non arriverà mai.
La versione che gestisce l'errore:
} else if (type === "magic_link") {
const { error } = await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: redirectURL },
});
if (error) {
// status 429 = hai superato il rate limit di Supabase
toast.error(
error.status === 429
? "Troppi tentativi. Riprova tra qualche minuto."
: "Non sono riuscito a mandare il link. Riprova."
);
return;
}
toast.success("Controlla la tua email!");
setIsDisabled(true);
}I limiti che Supabase applica già
Nella dashboard del progetto, sotto Authentication → Rate Limits, trovi i limiti configurabili. Quelli che contano per il magic link:
- Email inviate all'ora. Se stai usando il servizio email integrato di Supabase il tetto è bassissimo (poche email all'ora per tutto il progetto) e non è modificabile: è pensato solo per lo sviluppo. Diventa configurabile quando colleghi un SMTP tuo.
- Intervallo minimo tra due email allo stesso indirizzo. Di default 60 secondi. Prima che scada, una seconda richiesta per la stessa email torna 429 senza mandare nulla. È la tua difesa gratuita contro il refresh compulsivo.
- Tentativi di verifica del token: quante volte si può provare a bruciare un OTP. Serve contro chi tira a indovinare il codice a 6 cifre.
- Scadenza del link: sotto Authentication → Providers → Email. Il default è generoso; abbassalo a un'ora o meno. Un magic link è una credenziale valida a tutti gli effetti e vive nella casella di posta per sempre.
Il passaggio a SMTP tuo è quello che sblocca tutto il resto: in Project Settings → Authentication → SMTP Settings incolli le credenziali SMTP di Resend, e da lì i magic link partono dal tuo dominio verificato con un limite orario che decidi tu. Vedi Mandare email (Resend). A quel punto però il tetto di invio di Resend diventa il tuo tetto reale: leggi Limiti di invio email (Resend).
Blinda i redirect URL
Nel repo il redirect è costruito così, sia per il magic link che per OAuth:
const redirectURL = window.location.origin + "/api/auth/callback";Cioè l'origin del browser. Va benissimo, a patto che nella dashboard Supabase (Authentication → URL Configuration) la allow list dei redirect contenga esattamente gli origin che usi: http://localhost:3000/** per lo sviluppo e il tuo dominio di produzione. Non mettere mai un * generico per far funzionare le preview di Vercel: un redirect aperto significa che il codice di sessione può essere consegnato a un dominio di qualcun altro, e a quel punto il magic link diventa un takeover di account. Se ti servono le preview, aggiungi il pattern preciso del progetto Vercel e nient'altro.
Aggiungi un captcha
Supabase supporta hCaptcha e Cloudflare Turnstile nativamente: lo abiliti in Authentication → Settings → Bot and Abuse Protection, poi passi il token nelle opzioni. Nel repo non è configurato, va aggiunto:
await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: redirectURL,
captchaToken, // dal widget Turnstile / hCaptcha
},
});È il rapporto sforzo/risultato migliore che esista contro gli script automatici, e non dà fastidio agli utenti veri.
Protezione lato app: passa da una tua route
Se vuoi controllo totale (per esempio limitare per IP, cosa che Supabase da solo non fa per te), sposta la chiamata sul server e mettici davanti il tuo rate limiter. Questo file non c'è nel repo: crealo.
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createClient } from "@/libs/supabase/server";
import { rateLimit, getIp } from "@/libs/rate-limit";
import config from "@/config";
const schema = z.object({ email: z.string().email().max(254) });
const HOUR = 60 * 60 * 1000;
export async function POST(req: NextRequest) {
const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Email non valida." }, { status: 400 });
}
const email = parsed.data.email.toLowerCase();
const ip = getIp(req);
// 5 richieste all'ora per IP, 3 all'ora per indirizzo
const byIp = rateLimit({ key: "magic:ip:" + ip, limit: 5, windowMs: HOUR });
const byEmail = rateLimit({ key: "magic:mail:" + email, limit: 3, windowMs: HOUR });
if (!byIp.ok || !byEmail.ok) {
const retryAfter = Math.max(byIp.retryAfter, byEmail.retryAfter);
return NextResponse.json(
{ error: "Troppi tentativi. Riprova più tardi." },
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
);
}
const supabase = await createClient();
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: "https://" + config.domainName + "/api/auth/callback",
},
});
// Logghiamo l'errore ma rispondiamo sempre uguale: non diciamo a nessuno
// se quell'indirizzo esiste nel database o no.
if (error) console.error("magic link:", error.message);
return NextResponse.json({ ok: true });
}rateLimit e getIp sono in Rate limiting: API route — anche quel file va creato. Dal client la chiami con apiClient.post("/auth/magic-link", { email }) usando libs/api.ts, che ti mostra già il messaggio di errore in un toast.
Chi può registrarsi
Se il tuo prodotto è solo per chi ha pagato, spegni Allow new users to sign up in Authentication → Sign In / Providers. Nessuno sconosciuto può più farsi mandare un magic link, e il problema sparisce alla radice. Attenzione a un dettaglio di questo repo: il webhook Stripe crea gli utenti con supabase.auth.admin.createUser(), che usa la SUPABASE_SECRET_KEY e bypassa quell'impostazione. Quindi i tuoi clienti veri continuano a essere creati normalmente dopo il pagamento.