La llave vive en el servidor y nunca sale al navegador
Web · Intermedio
Escrituras seguras desde una landingguarda registros en tu base sin exponer la llave
Todas mis landings de registro comparten un mismo endpoint, con pequeñas variantes. Lo escribí mal la primera vez (escrituras desde el cliente), lo corregí, y después de decenas de embudos y un par de sustos quedó un patrón que ya no cambio: formulario, API route, service role, respuesta rápida, y todo lo demás en segundo plano. Aquí va completo, con el porqué de cada línea, para que lo copies y no repitas mis vueltas.
De un vistazo
Una sola ruta valida, guarda y responde
Dos auditores que buscan las fugas por ti
01 el punto de partida
El patrón: formulario, ruta, llave, respuesta
Supabase te da dos tipos de llave. La publicable (antes anon) está diseñada para vivir en el navegador: cualquiera la puede leer del bundle, y por eso solo puede hacer lo que las policies de Row Level Security le permitan. La secreta (antes service role) tiene el atributo BYPASSRLS: se salta todas las policies y tiene acceso total a los datos. Esa es la que escribe registros, y esa es la que jamás puede salir del servidor.
Un fetch POST a /api/registro con JSON. No importa nada de Supabase, no conoce ninguna llave. Su único trabajo es mandar los campos y mostrar el resultado.
Un route.ts en app/api/registro/. Valida, normaliza, escribe con la llave secreta y responde. Es el único lugar del proyecto que crea el cliente de Supabase con service role.
Tabla con RLS activado y sin policies para anon. Desde el navegador no se puede leer ni escribir; desde la ruta, con la llave secreta, sí.
CRM, píxel del lado servidor, hoja de cálculo del equipo. Todo lo que no decide si el registro existe corre con after() cuando la respuesta ya salió.
Por qué API route y no server action: la ruta la puede llamar cualquier cosa (un formulario, un webhook, un script de migración, otro servicio) con el mismo contrato. Una server action solo la llama tu propio frontend y esconde el endpoint. Para lógica de backend, ruta.
02 la llave
El cliente de Supabase que solo existe en el servidor
Un solo archivo crea el cliente con la llave secreta. Ningún componente con 'use client' lo importa jamás. Si en una auditoría encuentras un import de este archivo desde un componente de cliente, ya tienes la fuga.
git clone https://github.com/davidiriza-lab/registro-seguro-landing.git
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
let cached: SupabaseClient | null = null;
/** Cliente con la llave secreta. Solo se importa desde route.ts y código de servidor. */
export function getSupabaseServer(): SupabaseClient {
if (cached) return cached;
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key) {
throw new Error("Faltan SUPABASE_URL o SUPABASE_SERVICE_ROLE_KEY en el entorno");
}
cached = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
return cached;
}Fíjate en dos cosas. La variable se llama SUPABASE_SERVICE_ROLE_KEY, sin NEXT_PUBLIC_: Next.js solo inyecta al bundle del navegador las que llevan ese prefijo, así que el nombre es la barrera. Y persistSession en false: este cliente no representa a ningún usuario, no debe guardar ni refrescar sesiones.
# Servidor: nunca con prefijo NEXT_PUBLIC_ SUPABASE_URL=https://tu-proyecto.supabase.co SUPABASE_SERVICE_ROLE_KEY=sb_secret_... CRM_API_TOKEN=... META_PIXEL_ID=... META_CAPI_TOKEN=... # Cliente: solo si la landing LEE algo público. Para registrar, no hace falta. # NEXT_PUBLIC_SUPABASE_URL=https://tu-proyecto.supabase.co # NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
03 el archivo
route.ts completo y tipado
Este es el endpoint entero. Sin librerías de validación: para tres campos, un par de funciones bastan y no meten peso al proyecto. Lee el flujo de arriba a abajo: rate limit, JSON, honeypot, validación, normalización, una escritura esperada, respuesta, y lo demás en after().
import { NextRequest, NextResponse, after } from "next/server";
import { getSupabaseServer } from "@/lib/supabase-server";
import { normalizarTelefono, validarTelefono } from "@/lib/telefono";
import { rateLimit, ipDe } from "@/lib/rate-limit";
import { upsertCrm } from "@/lib/crm";
import { enviarCapi } from "@/lib/capi";
/** Presupuesto del lambda. Sin esto corre con el default de la plataforma y
* el trabajo de after() puede morir a la mitad sin dejar rastro. */
export const maxDuration = 30;
interface RegistroBody {
nombre?: unknown;
email?: unknown;
telefono?: unknown;
pais?: unknown;
utm_source?: unknown;
utm_medium?: unknown;
utm_campaign?: unknown;
utm_content?: unknown;
utm_term?: unknown;
event_id?: unknown;
fbp?: unknown;
fbc?: unknown;
/** Honeypot: campo oculto que un humano nunca llena. */
website?: unknown;
}
interface RegistroValido {
nombre: string;
email: string;
telefono: string;
pais: "MX" | "US" | "OTRO";
utm: Record<"utm_source" | "utm_medium" | "utm_campaign" | "utm_content" | "utm_term", string | null>;
eventId: string;
fbp: string | null;
fbc: string | null;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function str(v: unknown, max: number): string {
return typeof v === "string" ? v.trim().slice(0, max) : "";
}
function utm(v: unknown): string | null {
let s = str(v, 256);
try { s = decodeURIComponent(s.replace(/\+/g, " ")).trim(); } catch { /* se queda como venía */ }
// Plantillas sin rellenar de la plataforma de anuncios: {{campaign.name}}
if (/^\{\{.+\}\}$/.test(s)) return null;
return s || null;
}
function validar(body: RegistroBody): { ok: true; data: RegistroValido } | { ok: false; error: string } {
const nombre = str(body.nombre, 120);
const email = str(body.email, 254).toLowerCase();
const paisRaw = str(body.pais, 2).toUpperCase();
const pais: RegistroValido["pais"] = paisRaw === "MX" || paisRaw === "US" ? paisRaw : "OTRO";
const telefono = normalizarTelefono(str(body.telefono, 32), pais);
if (nombre.length < 2) return { ok: false, error: "Escribe tu nombre." };
if (!EMAIL_RE.test(email)) return { ok: false, error: "Revisa tu correo." };
const errTel = validarTelefono(telefono);
if (errTel) return { ok: false, error: errTel };
const eventId = str(body.event_id, 64) || crypto.randomUUID();
return {
ok: true,
data: {
nombre, email, telefono, pais, eventId,
utm: {
utm_source: utm(body.utm_source),
utm_medium: utm(body.utm_medium),
utm_campaign: utm(body.utm_campaign),
utm_content: utm(body.utm_content),
utm_term: utm(body.utm_term),
},
fbp: str(body.fbp, 128) || null,
fbc: str(body.fbc, 256) || null,
},
};
}
export async function POST(req: NextRequest) {
// 1. Rate limit por IP: 10 registros por minuto es de sobra para humanos.
const ip = ipDe(req);
if (!rateLimit(`registro:${ip}`, 10, 60_000)) {
return NextResponse.json({ ok: false, error: "Demasiados intentos. Espera un momento." }, { status: 429 });
}
// 2. JSON válido
let body: RegistroBody;
try {
body = (await req.json()) as RegistroBody;
} catch {
return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
}
// 3. Honeypot: si el campo oculto trae algo, es un bot. Se le responde OK
// para que no aprenda, y no se guarda nada.
if (str(body.website, 10).length > 0) {
return NextResponse.json({ ok: true });
}
// 4. Validación y normalización
const v = validar(body);
if (!v.ok) {
return NextResponse.json({ ok: false, error: v.error }, { status: 400 });
}
const d = v.data;
const userAgent = req.headers.get("user-agent") ?? "";
const ipCountry = req.headers.get("x-vercel-ip-country") ?? null;
// 5. La ÚNICA escritura que se espera: si esto falla, el registro no existe.
const supabase = getSupabaseServer();
const { error } = await supabase.from("registros").insert({
nombre: d.nombre,
email: d.email,
telefono: d.telefono,
pais: d.pais,
event_id: d.eventId,
...d.utm,
fbp: d.fbp,
fbc: d.fbc,
ip_country: ipCountry,
user_agent: userAgent.slice(0, 512),
});
let yaRegistrado = false;
if (error) {
// 23505 = unique violation (email + embudo). No es error para la persona.
if (error.code === "23505") {
yaRegistrado = true;
} else {
console.error("[registro] supabase:", error.message);
return NextResponse.json({ ok: false, error: "No pudimos guardar tu registro." }, { status: 500 });
}
}
// 6. Todo lo demás corre DESPUÉS de responder. Si el CRM cae, el registro ya está.
after(async () => {
const r = await upsertCrm({
nombre: d.nombre,
email: d.email,
telefono: d.telefono, // ya en E.164
tags: ["landing-registro"],
utm: d.utm,
});
if (!r.ok) console.error("[registro] crm:", r.motivo);
});
if (!yaRegistrado) {
after(async () => {
const r = await enviarCapi({
eventName: "Lead",
eventId: d.eventId, // el mismo que disparó el píxel en el navegador
email: d.email,
telefono: d.telefono,
nombre: d.nombre,
ip,
userAgent,
fbp: d.fbp,
fbc: d.fbc,
sourceUrl: req.headers.get("referer") ?? "",
});
if (!r.ok) console.error("[registro] capi:", r.motivo);
});
}
return NextResponse.json({ ok: true, lead_id: d.eventId, already_registered: yaRegistrado });
}Tres decisiones que no son obvias. Primero: al duplicado (23505) no se le responde error. La persona ya estaba registrada, no tiene por qué ver un fallo; y el upsert al CRM sí se repite porque es idempotente y así recuperas un lead cuyo primer envío al CRM falló. Segundo: el píxel del lado servidor no se repite en duplicados, porque ahí sí contarías dos veces. Tercero: el evento_id viaja desde el navegador para que el píxel de navegador y el de servidor se dedupliquen; si no llega, se genera uno.
04 las piezas
Teléfono a E.164, rate limit y honeypot
El teléfono es donde más leads perdí sin enterarme. La gente escribe su número nacional sin lada. Supabase lo guarda feliz. El CRM lo rechaza en silencio, y como ese envío corre en segundo plano, el error solo va a la consola: el lead existe en tu base, nunca llega al CRM y jamás dispara la automatización. Me tomó semanas notarlo, y solo porque mis propias pruebas 'no caían'. Desde entonces el teléfono se normaliza antes de cualquier upsert.
export type PaisTel = "MX" | "US" | "OTRO";
/** Normaliza a E.164 (+<lada><número>). Respeta lo que ya trae "+";
* a un nacional de 10 dígitos le antepone la lada del país. */
export function normalizarTelefono(raw: string, pais: PaisTel): string {
let digits = raw.replace(/\D/g, "");
if (!digits) return "";
if (digits.startsWith("00")) digits = digits.slice(2); // prefijo internacional
if (pais === "MX") {
if (digits.startsWith("5252")) digits = digits.slice(2); // lada duplicada
if (digits.startsWith("521") && digits.length === 13) return "+" + digits; // formato viejo 52 1
if (digits.startsWith("52") && digits.length === 12) return "+" + digits;
if (digits.length === 10) return "+52" + digits;
return "+" + digits;
}
if (pais === "US") {
if (digits.startsWith("11") && digits.length === 12) digits = digits.slice(1);
if (digits.startsWith("1") && digits.length === 11) return "+" + digits;
if (digits.length === 10) return "+1" + digits;
return "+" + digits;
}
return "+" + digits; // multipaís: no se infiere lada
}
/** null = válido; string = mensaje para la persona. MX y US exigen 10 dígitos
* nacionales exactos: el typo clásico es uno de más o uno de menos. */
export function validarTelefono(e164: string): string | null {
const digits = e164.replace(/\D/g, "");
if (!digits) return "Escribe tu WhatsApp.";
if (e164.startsWith("+52")) {
const n = digits.slice(2);
return n.length === 10 ? null : `Tu número de México debe tener 10 dígitos después de la lada (escribiste ${n.length}).`;
}
if (e164.startsWith("+1")) {
const n = digits.slice(1);
if (n.length !== 10) return `Tu número debe tener 10 dígitos después de la lada (escribiste ${n.length}).`;
if (/^[01]/.test(n) || /^[01]/.test(n.slice(3))) return "Ese número no parece válido, revísalo.";
return null;
}
if (digits.length < 8 || digits.length > 14) return "Revisa tu número, parece incompleto.";
return null;
}import type { NextRequest } from "next/server";
/** Ventana fija en memoria. Suficiente contra un bot tonto y contra el doble
* clic; en serverless cada instancia tiene su propio mapa, así que no es un
* límite global. Para eso usa un store compartido (Redis/Upstash). */
const buckets = new Map<string, { n: number; reset: number }>();
export function rateLimit(key: string, max: number, windowMs: number): boolean {
const now = Date.now();
const b = buckets.get(key);
if (!b || b.reset < now) {
buckets.set(key, { n: 1, reset: now + windowMs });
return true;
}
b.n += 1;
return b.n <= max;
}
export function ipDe(req: NextRequest): string {
const xff = req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0]?.trim() ?? "";
return req.headers.get("x-real-ip") ?? "0.0.0.0";
}El honeypot es un input oculto con CSS (no con type=hidden, que los bots respetan) llamado con un nombre creíble como website. Un humano no lo ve ni lo llena; un bot que rellena todo lo llena. Si llega con contenido, respondes 200 y no guardas: al bot le dices que todo bien y se va.
"use client";
import { useState, type FormEvent } from "react";
interface Resp { ok: boolean; error?: string; lead_id?: string }
export function FormularioRegistro() {
const [estado, setEstado] = useState<"idle" | "enviando" | "ok" | "error">("idle");
const [mensaje, setMensaje] = useState("");
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setEstado("enviando");
const fd = new FormData(e.currentTarget);
const payload = Object.fromEntries(fd.entries());
const res = await fetch("/api/registro", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...payload, event_id: crypto.randomUUID() }),
});
const data = (await res.json()) as Resp;
if (data.ok) { setEstado("ok"); return; }
setEstado("error");
setMensaje(data.error ?? "Algo salió mal.");
}
return (
<form onSubmit={onSubmit}>
<input name="nombre" required minLength={2} autoComplete="name" />
<input name="email" type="email" required autoComplete="email" />
<input name="telefono" type="tel" required inputMode="tel" autoComplete="tel" />
<input type="hidden" name="pais" value="MX" />
{/* Honeypot: oculto con CSS, no con type=hidden */}
<input name="website" tabIndex={-1} autoComplete="off" style={{ position: "absolute", left: "-9999px" }} aria-hidden="true" />
<button disabled={estado === "enviando"}>Registrarme</button>
{estado === "error" && <p role="alert">{mensaje}</p>}
</form>
);
}05 después de responder
CRM y píxel como fire-and-forget con after()
La regla: solo se espera la escritura que decide si el registro existe. El CRM, el píxel del lado servidor, la hoja del equipo, el WhatsApp de bienvenida, todo eso son copias y avisos. Si uno cae, la persona no debe ver un error ni esperar tres segundos. after() de next/server programa trabajo para cuando la respuesta ya terminó; en Vercel extiende la vida del lambda hasta que las promesas se resuelvan.
- Declara maxDuration en la ruta. Sin eso corre con el default de la plataforma, y si el CRM tarda, el lambda muere con el envío a medias y sin registrar nada. En un embudo con reintentos medí el peor caso en unos 39 segundos; 60 dejó margen.
- Ponle timeout a cada fetch de segundo plano (AbortSignal.timeout(8000)). Una llamada colgada se come el presupuesto entero y mata al resto del fan-out.
- Reintenta solo lo reintentable: 429 y 5xx, y errores de red. Un 4xx que no sea 429 (correo malformado, teléfono inválido) va a fallar igual tres veces.
- No te tragues el error. console.error como mínimo; mejor, una tabla integration_failures con endpoint, correo, teléfono y motivo, para reenviar después. Es lo que convierte al fire-and-forget en algo confiable.
- Correos con punto doble o punto final los rechazan varios CRMs con 4xx permanente. En 60 días conté 84 así; una normalización simple recuperó 80. Guarda en tu base lo que la persona escribió; manda al CRM la versión saneada.
interface CrmInput {
nombre: string;
email: string;
telefono: string; // E.164
tags: string[];
utm: Record<string, string | null>;
}
type Resultado = { ok: true } | { ok: false; motivo: string };
export async function upsertCrm(input: CrmInput): Promise<Resultado> {
const token = process.env.CRM_API_TOKEN;
if (!token) return { ok: false, motivo: "sin CRM_API_TOKEN" };
const [firstName, ...resto] = input.nombre.split(" ");
const body = {
firstName,
lastName: resto.join(" "),
email: input.email,
phone: input.telefono,
tags: input.tags,
customFields: Object.entries(input.utm)
.filter((par): par is [string, string] => par[1] !== null)
.map(([key, value]) => ({ key, value })),
};
const MAX = 3;
let ultimo = "";
for (let i = 1; i <= MAX; i++) {
try {
const r = await fetch("https://api.tu-crm.com/contacts/upsert", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
signal: AbortSignal.timeout(8000),
});
if (r.ok) return { ok: true };
ultimo = `${r.status} ${await r.text()}`;
if (r.status !== 429 && r.status < 500) break; // permanente: no reintentar
} catch (e) {
ultimo = String(e);
}
if (i < MAX) await new Promise((res) => setTimeout(res, 1000 * 2 ** (i - 1)));
}
return { ok: false, motivo: ultimo };
}after() se ejecuta aunque la respuesta haya fallado o hayas hecho redirect. Por eso el orden importa: llámalo después de la escritura, no antes; si la escritura falló y regresaste 500, no quieres un contacto en el CRM que no existe en tu base.
06 el lado de la base
RLS activado, cero policies, y la trampa de las funciones
Con este patrón el navegador no necesita tocar la tabla. Entonces la configuración correcta es la más simple: RLS activado y ninguna policy. Supabase lo dice literal: una vez activado RLS, no hay datos accesibles por la API con la llave publicable hasta que crees policies. La llave secreta se lo salta y tu ruta escribe normal.
create table if not exists public.registros ( id uuid primary key default gen_random_uuid(), created_at timestamptz not null default now(), nombre text not null, email text not null, telefono text not null, pais text, event_id text, utm_source text, utm_medium text, utm_campaign text, utm_content text, utm_term text, fbp text, fbc text, ip_country text, user_agent text, unique (email) ); alter table public.registros enable row level security; -- Sin policies: anon y authenticated no leen ni escriben. revoke all on table public.registros from anon, authenticated;
Ahora la trampa. En un proyecto con más de 800 mil contactos y RLS deny-all correcto en todas las tablas, una auditoría encontró que con la llave publicable del bundle se podía llamar /rest/v1/rpc/buscar_contactos y volcar nombre, correo, teléfono y valor de compra de todos. HTTP 200 con PII. La causa: las funciones de Postgres se crean con EXECUTE concedido a PUBLIC por defecto, y una función SECURITY DEFINER corre con los privilegios de su dueño, que en Supabase suele ser postgres y se salta RLS. La RLS de la tabla no importa si la función la lee por ti.
-- Revocar de PUBLIC es lo que cuenta: anon y authenticated heredan de ahí. revoke execute on all functions in schema public from public, anon, authenticated; grant execute on all functions in schema public to service_role; -- Que las funciones futuras nazcan cerradas alter default privileges in schema public revoke execute on functions from public; alter default privileges in schema public grant execute on functions to service_role;
- Revocar solo de anon y authenticated NO basta: heredan el permiso vía PUBLIC. Hay que revocarlo de PUBLIC.
- En toda función SECURITY DEFINER pon set search_path = '' y califica los nombres con el esquema. Es la recomendación oficial y evita escaladas por resolución de nombres.
- Verifica en vivo: un curl con la llave publicable a /rest/v1/rpc/tu_funcion debe regresar 401 o 403. Si regresa 200, sigue abierto.
- Corre los Security Advisors del panel de Supabase después de cada migración. Te marcan tablas sin RLS y funciones sin search_path.
curl -s -o /dev/null -w '%{http_code}\n' -H "apikey: $PUBLISHABLE_KEY" -H "Authorization: Bearer $PUBLISHABLE_KEY" "https://tu-proyecto.supabase.co/rest/v1/registros?select=email&limit=1"07 la capa de arriba
Cabeceras de seguridad y la decisión sobre CORS
La llave y la RLS protegen la base. Pero la landing también es una página, y una página sin cabeceras de seguridad se puede meter en un iframe ajeno, servir por HTTP o ejecutar scripts que tú no pusiste. Son seis líneas en next.config.ts y se aplican a todas las rutas; no hay razón para publicar sin ellas.
import type { NextConfig } from "next";
const cabeceras = [
// HTTPS siempre, también en subdominios, por 2 años
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
// Nadie mete tu landing en un iframe (X-Frame-Options es el respaldo para navegadores viejos)
{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
// El navegador no "adivina" tipos de archivo
{ key: "X-Content-Type-Options", value: "nosniff" },
// Al salir a otro dominio solo viaja el origen, no la URL completa con UTMs
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
// Una landing no usa cámara, micrófono ni GPS
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
];
const nextConfig: NextConfig = {
async headers() {
return [{ source: "/:path*", headers: cabeceras }];
},
};
export default nextConfig;- frame-ancestors dentro de Content-Security-Policy es lo que hoy manda; X-Frame-Options quedó como respaldo. Si tu landing se incrusta a propósito en otro dominio (un portal del cliente, una plataforma de cursos), agrégalo ahí en vez de 'self'.
- Una CSP completa (script-src, img-src, connect-src) vale la pena, pero cada píxel y cada widget de chat te la rompe si no lo listas. Empieza por frame-ancestors, y sube a la CSP completa en una segunda pasada mirando la consola: cada violación sale ahí con el dominio que falta.
- Referrer-Policy importa más de lo que parece en una landing con UTMs: sin ella, la URL entera con utm_campaign y fbclid viaja en el Referer a cada dominio de terceros que cargues.
curl -sI https://tu-landing.com | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy'
CORS: en la mayoría de las landings, nada
El formulario y /api/registro viven en el mismo dominio, así que el navegador no hace ninguna verificación de origen y no necesitas cabeceras CORS. Agregar Access-Control-Allow-Origin: * 'por si acaso' es lo contrario de seguro: le dices a cualquier página del mundo que puede llamar tu endpoint desde el navegador de sus visitantes. La única situación donde sí necesitas CORS es cuando el formulario se incrusta en otro dominio (una página del cliente, un constructor de páginas) y de ahí manda el POST. Entonces la regla es lista cerrada de orígenes y contestar el preflight OPTIONS.
const ORIGENES = new Set(["https://landing-del-cliente.com", "https://www.landing-del-cliente.com"]);
function corsHeaders(req: NextRequest): Record<string, string> {
const origin = req.headers.get("origin") ?? "";
// Solo si está en la lista se devuelve; jamás se refleja el Origin tal cual llegó
if (!ORIGENES.has(origin)) return {};
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
Vary: "Origin",
};
}
export async function OPTIONS(req: NextRequest) {
return new NextResponse(null, { status: 204, headers: corsHeaders(req) });
}
// En POST: NextResponse.json({ ok: true }, { headers: corsHeaders(req) })Tres errores que veo en endpoints de registro: reflejar el Origin que llega (equivale a *), mezclar * con credenciales (el navegador lo bloquea, y quien lo 'arregla' abre un hueco), y permitir la versión http:// del dominio junto a la https://. Y ojo: CORS solo frena navegadores. Un script con curl lo ignora; contra eso están el honeypot, el rate limit y la validación de arriba.
08 auditar sin confiar en tu memoria
Dos auditores que corren solos: el Advisor de Supabase y un plugin de Claude Code
El checklist de abajo lo haces a mano. Estas dos herramientas lo hacen por ti, y las corro después de cada migración porque lo que se me escapa siempre es lo que 'ya había revisado'.
Security Advisor (panel de Supabase, sección Database)
Es un linter de tu esquema que ya viene con el proyecto. Para este patrón hay tres avisos que te importan. 'Table publicly accessible' (rls_disabled_in_public): una tabla del esquema public sin RLS; nunca debe salir. 'Privileged function callable without authentication' (anon_security_definer_function_executable): es exactamente la trampa de la sección anterior, y el advisor la detecta antes de que un curl lo haga. Y 'No access rules defined' (rls_enabled_no_policy): tu tabla con RLS y cero policies; en este patrón es el estado deseado, no un error, porque escribes con la llave secreta. Léelo, entiende por qué aparece, y sigue.
claude-db: auditoría del esquema desde Claude Code
Es un plugin abierto (MIT, repo Hainrixz/claude-db) que lee tu schema.sql, tus migraciones o tu ORM y te regresa dos calificaciones separadas, diseño e integridad por un lado, rendimiento y escala por otro, con cada hallazgo acompañado de un comando para reproducirlo. Lo instalé y lo corrí contra el schema.sql de esta guía: sus scripts parsean la tabla, el detector de antipatrones y el de llaves foráneas sin índice regresaron vacío, y el hook que bloquea SQL destructivo (DROP, TRUNCATE, DELETE sin WHERE) funciona como dice. Sin conexión a la base trabaja solo con archivos; con una URL de solo lectura inspecciona el catálogo real, incluido el estado de RLS.
/plugin marketplace add Hainrixz/claude-db && /plugin install claude-db@claude-db && /reload-plugins
/claude-db:audit sql/schema.sql
| Herramienta | Qué ve | Qué no ve |
|---|---|---|
| Security Advisor | RLS apagada, funciones SECURITY DEFINER abiertas a anon, policies con user_metadata, vistas que exponen auth.users | Tu código de Next.js: la llave en NEXT_PUBLIC_, el import desde 'use client' |
| claude-db | Llaves, tipos, índices que faltan, migraciones irreversibles, pooling en serverless | Si tus policies son correctas (lo declara como advisory) |
| El grep del checklist | Fugas de la llave en el bundle y en los imports | Nada del lado de la base |
09 antes de publicar
Checklist de variables y de fugas
| Variable | Prefijo | Dónde vive | Quién la usa |
|---|---|---|---|
| SUPABASE_URL | sin prefijo | Vercel + .env.local | lib/supabase-server.ts |
| SUPABASE_SERVICE_ROLE_KEY | sin prefijo, JAMÁS NEXT_PUBLIC_ | Vercel + .env.local | lib/supabase-server.ts |
| CRM_API_TOKEN | sin prefijo | Vercel + .env.local | lib/crm.ts, dentro de after() |
| META_CAPI_TOKEN | sin prefijo | Vercel + .env.local | lib/capi.ts, dentro de after() |
| NEXT_PUBLIC_META_PIXEL_ID | NEXT_PUBLIC_ | Vercel + .env.local | el píxel de navegador; no es secreto |
| NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | NEXT_PUBLIC_ | solo si el cliente lee algo | componentes de cliente |
Auditoría de dos minutos
- grep -r NEXT_PUBLIC_SUPABASE_SERVICE en el repo. Debe regresar vacío. Lo he visto en producción más de una vez.
- grep -r "supabase-server" src/ y confirma que ningún archivo con 'use client' lo importa.
- Abre el bundle en producción (DevTools, pestaña Sources, busca sb_secret o service_role). Nada.
- En Vercel, al agregar variables por CLI usa --value; pasarlas por stdin en algunas versiones guarda una cadena vacía y la ruta arranca sin llave.
- Un POST a /api/registro con teléfono de 9 dígitos debe regresar 400 con mensaje claro. Uno con el honeypot lleno regresa 200 y no crea fila.
- Después de un registro de prueba, revisa que el contacto exista en el CRM con el teléfono en E.164. Si no está, lee la consola de la función: ahí vive el error que el usuario nunca vio.
- curl -sI a la landing en producción: las seis cabeceras de seguridad presentes. Y una pasada por el Security Advisor de Supabase sin 'Table publicly accessible' ni funciones abiertas a anon.
Los dashboards que leen esta tabla (métricas, lista de registros) siguen la misma regla: página como Server Component, lectura con la llave secreta, y contraseña verificada en el servidor. Nunca una tabla abierta a anon 'solo para leer'.
FAQ lo que suelen preguntar
Preguntas frecuentes
¿No es más fácil escribir directo desde el cliente con la llave publicable y una policy de INSERT?
Funciona, pero te obliga a validar en la base (constraints y checks) todo lo que aquí validas en código, no puedes normalizar el teléfono ni disparar CRM y píxel del lado servidor, y cualquiera puede insertar con la llave del bundle a la velocidad que quiera. Para un formulario público, la ruta te da control y un solo punto de auditoría.
¿Qué pasa si mi CRM se cae? ¿Pierdo el lead?
No: el lead ya está en tu base porque esa escritura es la única que se espera. Lo que pierdes es la sincronización, y para eso sirve registrar la falla en una tabla y tener un script que reenvíe lo pendiente. Con el upsert idempotente, además, un segundo registro de la misma persona vuelve a intentar el envío.
¿after() funciona fuera de Vercel?
En un servidor Node o un contenedor Docker, sí. En export estático, no. En otras plataformas serverless depende de que exista un waitUntil equivalente; la documentación de Next explica cómo proveerlo. Si no lo tienes, la alternativa es esperar los envíos con Promise.allSettled y aceptar la latencia extra.
¿El rate limit en memoria sirve de algo en serverless?
Sirve contra el doble clic y contra un bot que golpea una misma instancia caliente. No sirve como límite global: cada instancia tiene su propio mapa. Si te ataca alguien con ganas, usa un store compartido tipo Upstash Redis con la misma interfaz. Para la mayoría de landings, el honeypot filtra más que el rate limit.
¿Cómo sé si tengo funciones SECURITY DEFINER expuestas?
En el SQL editor: select proname, prosecdef from pg_proc where pronamespace = 'public'::regnamespace. Las que tengan prosecdef en true son las peligrosas. Luego prueba cada una con la llave publicable contra /rest/v1/rpc/nombre. Y corre el bloque de REVOKE de esta guía de todos modos: cuesta nada y cierra las futuras.
¿Y si mi app sí tiene usuarios que deben ver sus propios registros?
Ahí cambia el juego: el navegador lee con la llave publicable y las policies deciden qué fila ve cada quien. Cuatro reglas que me ahorraron problemas. Una policy por operación (SELECT, INSERT, UPDATE, DELETE), nunca una sola FOR ALL. Compara con auth.uid() y pide además que no sea null: USING (auth.uid() IS NOT NULL AND auth.uid() = user_id). Jamás decidas permisos con user_metadata, porque el propio usuario la puede editar; lo que sea autorización va en app_metadata. Y pon índice a la columna que usa la policy (user_id), porque Postgres la evalúa fila por fila y sin índice cada lectura es un scan completo. Pruébalo con dos usuarios desde el SDK, no desde el SQL editor: el editor corre como postgres y se salta la RLS, así que ahí todo 'funciona'.
¿Necesito configurar CORS en /api/registro?
Si el formulario y la ruta están en el mismo dominio, no, y agregar Access-Control-Allow-Origin: * solo abre tu endpoint a cualquier página. Lo necesitas únicamente cuando el formulario se incrusta en otro dominio y desde ahí manda el POST: lista cerrada de orígenes, handler OPTIONS, y nunca reflejar el Origin que llega. Y recuerda que CORS no detiene a un script: eso lo hacen el honeypot, el rate limit y la validación.
El Security Advisor me marca la tabla de registros con 'No access rules defined'. ¿Está mal?
No: es la descripción exacta de este patrón. RLS activada y cero policies significa que anon y authenticated no ven ni una fila, y tu ruta escribe con la llave secreta que se salta la RLS. El aviso existe porque en una app con usuarios sería un olvido. Los que sí debes atender siempre son 'Table publicly accessible' (RLS apagada) y 'Privileged function callable without authentication' (una SECURITY DEFINER ejecutable por anon).
Cierre de la guía
El patrón cabe en una frase: el navegador solo manda, el servidor valida y guarda con la llave secreta, responde en cuanto la fila existe, y todo lo demás pasa después. Lo que lo hace confiable no es la arquitectura sino los detalles que aprendí perdiendo leads: el teléfono en E.164 antes del CRM, el timeout en cada fetch de segundo plano, el registro de fallas, el REVOKE que cierra las funciones que la RLS no cubre, y las cabeceras que protegen la página misma. Copia el route.ts, cambia el endpoint del CRM, corre el SQL, y pasa el checklist antes de publicar. Esta guía vive en el Lab de David Iriza.
Fuentes oficiales6
- Understanding API keys (Docs de Supabase) ↗Llaves publicables vs secretas: cuál puede vivir en el navegador, cuál tiene BYPASSRLS y las reglas de manejo de la secreta.
- Row Level Security (Docs de Supabase) ↗Cómo activar RLS, qué pasa sin policies, por qué service_role se la salta y la recomendación de search_path en funciones SECURITY DEFINER.
- Route Handlers: route.js (Docs de Next.js) ↗La convención route.ts, los métodos HTTP soportados, NextRequest y la configuración de segmento (maxDuration, runtime).
- headers en next.config (Docs de Next.js) ↗Cómo aplicar cabeceras a todas las rutas con source y headers, valores recomendados de HSTS, X-Frame-Options, Permissions-Policy, nosniff y Referrer-Policy, y la nota de que CSP frame-ancestors reemplaza a X-Frame-Options.
- Database Advisors (Docs de Supabase) ↗Los lints del Security Advisor: rls_disabled_in_public, rls_enabled_no_policy, anon_security_definer_function_executable, rls_references_user_metadata y el resto.
- after (Docs de Next.js) ↗Trabajo después de la respuesta en Route Handlers: duración, uso con headers y cookies, y soporte por plataforma.
Sigue con estas guías
- Nº 010 · Píxel + CAPI sin duplicar →El event_id que aquí viaja del navegador a la ruta es lo que permite deduplicar el píxel de navegador con el de servidor.
- Nº 011 · UTMs que sobreviven 30 días →De dónde salen los utm_* que la ruta sanea y guarda con cada registro.
- Nº 009 · Preparar tu app para miles de usuarios →Cuando el rate limit en memoria se queda corto y la tabla crece: índices, pooling y un límite global con Redis.
- Nº 007 · Analytics propio en tu landing →El endpoint de tracking sigue exactamente el mismo patrón: ruta server-side, llave secreta, RLS cerrada.
Guía escrita con la información oficial disponible al 28 de agosto de 2026. Esta página no está afiliada a Supabase. Las herramientas cambian; ante la duda, revisa la documentación oficial de Supabase.
por David Iriza