ZeroToShip

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:

app/signin/page.tsx
tsx
} 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:

app/signin/page.tsx
tsx
} 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:

app/signin/page.tsx
tsx
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:

app/signin/page.tsx
tsx
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.

app/api/auth/magic-link/route.ts
ts
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.