---
title: "Escrituras seguras desde una landing · guarda registros en tu base sin exponer la llave"
description: "Una landing que recibe registros tiene un solo camino seguro para escribir en la base: el formulario manda a una API route de tu propio servidor, la ruta valida y guarda con la llave secreta, y responde. La llave nunca toca el navegador. Suena obvio y aun así el error más común en proyectos Next.js "
url: https://www.davidiriza.com/lab/escrituras-seguras-desde-una-landing
author: David Iriza
level: Intermedio
category: Web
tags: ["next.js", "supabase", "api routes", "seguridad", "landings"]
tools: ["Next.js", "Supabase", "TypeScript", "Vercel"]
published: 2026-08-28
---
# Escrituras seguras desde una landing: guarda 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.

**Ficha:** Requiere saber programar · Herramientas: Next.js, Supabase, TypeScript +1 · Te llevas: 16 comandos · Lectura: 26 min · Verificado 28-ago-2026

> Para agentes: esta guía describe pasos ejecutables. Sigue los bloques de comando y código en orden; pregunta al usuario solo lo que no puedas inferir (rutas, nombres de proyecto). Repositorio y contexto del sitio: https://www.davidiriza.com/llms.txt

## De un vistazo

1. El patrón: formulario, ruta, llave, respuesta
2. El cliente de Supabase que solo existe en el servidor
3. route.ts completo y tipado
4. Teléfono a E.164, rate limit y honeypot
5. CRM y píxel como fire-and-forget con after()
6. RLS activado, cero policies, y la trampa de las funciones
7. Cabeceras de seguridad y la decisión sobre CORS
8. Dos auditores que corren solos: el Advisor de Supabase y un plugin de Claude Code
9. Checklist de variables y de fugas

## 01 · El patrón: formulario, ruta, llave, respuesta

_el punto de partida_

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.

- **Formulario (cliente)** — 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.
- **API route (servidor)** — 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.
- **Base (Supabase)** — 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í.
- **Después de responder** — 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 · El cliente de Supabase que solo existe en el servidor

_la llave_

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.

**Atajo: clonar el repo de esta guía (terminal)**

```bash
git clone https://github.com/davidiriza-lab/registro-seguro-landing.git
```

> Repo público con licencia MIT: github.com/davidiriza-lab/registro-seguro-landing. Trae los archivos de abajo listos para copiar a tu proyecto.

**lib/supabase-server.ts**

```typescript
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.

**.env.local (solo servidor)**

```bash
# 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 · route.ts completo y tipado

_el archivo_

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().

**app/api/registro/route.ts**

```typescript
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 · Teléfono a E.164, rate limit y honeypot

_las piezas_

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.

**lib/telefono.ts**

```typescript
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;
}
```

**lib/rate-limit.ts**

```typescript
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.

**El formulario (cliente), lo mínimo**

```tsx
"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 · CRM y píxel como fire-and-forget con after()

_después de responder_

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.

**lib/crm.ts (genérico: cámbialo por el endpoint de tu CRM)**

```typescript
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 · RLS activado, cero policies, y la trampa de las funciones

_el lado de la base_

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.

**SQL: tabla cerrada al público**

```sql
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.

**SQL: cerrar las funciones al rol anónimo**

```sql
-- 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.

**Verificar que anon no puede ni leer la tabla ni ejecutar la función**

```bash
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"
```

> Con RLS activado y sin policies esperas 200 con un arreglo vacío (RLS filtra filas) o 401/403 si además revocaste los grants. Lo que nunca debe salir es una fila con datos.

## 07 · Cabeceras de seguridad y la decisión sobre CORS

_la capa de arriba_

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.

**next.config.ts**

```typescript
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.

**Verificar las cabeceras en producción**

```bash
curl -sI https://tu-landing.com | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy'
```

> Deben salir las seis. Si falta alguna, revisa que el deploy tomó el next.config.ts nuevo. También existen calificadores en línea tipo securityheaders.com que te dan una letra; el curl es más rápido y no depende de nadie.

### 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.

**Solo si el formulario vive en otro dominio: OPTIONS + origen permitido**

```typescript
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 · Dos auditores que corren solos: el Advisor de Supabase y un plugin de Claude Code

_auditar sin confiar en tu memoria_

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.

**Instalar como plugin (dentro de Claude Code)**

```bash
/plugin marketplace add Hainrixz/claude-db && /plugin install claude-db@claude-db && /reload-plugins
```

> Son tres comandos del prompt de Claude Code, uno por uno. Alternativa para Cursor, Codex o Gemini CLI: npx skills add Hainrixz/claude-db en la raíz del proyecto; a mí me dejó 35 skills en .agents/skills/ y los enlazó a Claude Code.

**Auditar el esquema de esta guía (solo lectura)**

```bash
/claude-db:audit sql/schema.sql
```

> Los comandos audit, explain, score, next y checklist no escriben nada. fix pide confirmación cambio por cambio y las operaciones destructivas exigen teclear el nombre del objeto. Lo que no hace, por diseño, es opinar sobre tus policies de RLS: eso sigue siendo tuyo.

| 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 · Checklist de variables y de fugas

_antes de publicar_

| 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

1. grep -r NEXT_PUBLIC_SUPABASE_SERVICE en el repo. Debe regresar vacío. Lo he visto en producción más de una vez.
2. grep -r "supabase-server" src/ y confirma que ningún archivo con 'use client' lo importa.
3. Abre el bundle en producción (DevTools, pestaña Sources, busca sb_secret o service_role). Nada.
4. 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.
5. 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.
6. 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.
7. 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'.

## 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 oficiales

- [Understanding API keys (Docs de Supabase)](https://supabase.com/docs/guides/api/api-keys): 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)](https://supabase.com/docs/guides/database/postgres/row-level-security): 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)](https://nextjs.org/docs/app/api-reference/file-conventions/route): 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)](https://nextjs.org/docs/app/api-reference/config/next-config-js/headers): 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)](https://supabase.com/docs/guides/database/database-advisors): 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)](https://nextjs.org/docs/app/api-reference/functions/after): Trabajo después de la respuesta en Route Handlers: duración, uso con headers y cookies, y soporte por plataforma.

## Repositorios

- [davidiriza-lab/registro-seguro-landing](https://github.com/davidiriza-lab/registro-seguro-landing): API route Next.js + Supabase para registros de landing: service role solo en servidor, honeypot, rate limit, E.164 y CRM/CAPI con after(). (MIT)

## Guías que se conectan con esta

- [pixel-capi-sin-duplicar](https://www.davidiriza.com/lab/pixel-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.
- [utms-que-sobreviven-30-dias](https://www.davidiriza.com/lab/utms-que-sobreviven-30-dias): De dónde salen los utm_* que la ruta sanea y guarda con cada registro.
- [preparar-tu-app-para-miles-de-usuarios](https://www.davidiriza.com/lab/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.
- [analytics-propio-en-tu-landing](https://www.davidiriza.com/lab/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. Ante la duda, revisa la documentación oficial de Supabase: https://supabase.com/docs

---

Versión markdown de https://www.davidiriza.com/lab/escrituras-seguras-desde-una-landing — el sitio negocia por `Accept: text/markdown` y por sufijo `.md`. Índice para agentes: https://www.davidiriza.com/llms.txt