CloudflareMódulo IV · Producción · Capítulo 11 de 15Nivel: BásicoEn desarrollo

11. Seguridad

Cómo proteger una aplicación en Cloudflare con reglas de WAF, Turnstile en los formularios, limitación de peticiones y Access para el panel de administración.

En desarrollo. La publico antes de terminarla porque ya puede servirte; la iré completando y anotaré los cambios al final.

Víctor Rodríguez5 min de lectura

Probado conTypeScript 7.0Wrangler 4.157Vitest 5.1

Un muro con puerta y escudo: el guardia deja pasar a las personas y los bots rebotan.

Qué vas a aprender

  • Proteger tu aplicación de bots y abusos sin molestar a las personas.
  • Limitar los intentos de inicio de sesión.
  • Que solo tu equipo pueda entrar en el panel interno.

Conocimientos iniciales

Si alguna de estas preguntas no te suena, ábrela: está explicada desde cero.

  • ¿Qué es un WAF?

    Cortafuegos de aplicaciones web: reglas que bloquean peticiones peligrosas o sospechosas (ataques conocidos, países, bots) antes de que lleguen a tu código.

  • ¿Qué es un bot?

    Un programa que visita webs sin una persona detrás. Algunos son útiles (el de Google), otros rellenan formularios con spam o prueban contraseñas.

  • ¿Qué es la limitación de peticiones?

    Poner un máximo de peticiones por persona y minuto (por ejemplo, 5 intentos de inicio de sesión). Frena a quien intenta adivinar contraseñas o abusar de tu API.

  • ¿Qué es un proxy?Repaso

    Un intermediario. Con el proxy de Cloudflare activado (la nube naranja), tus visitantes hablan con Cloudflare y Cloudflare con tu aplicación: así puede protegerla, cachearla y darle HTTPS.

    Lo vimos en el capítulo 2.

Una aplicación en internet recibe tráfico automatizado desde el primer día: bots que prueban contraseñas, formularios rellenados por spam, escáneres buscando /wp-admin. Cloudflare filtra mucho de esto sin que hagas nada; en este capítulo cierro lo que queda, por capas.

Para quién es y qué necesitas

Para quien va a abrir su aplicación al público y quiere protegerla de bots, abusos y accesos indebidos sin complicarse.

Antes de empezar:

  • La aplicación conectada a tu dominio (capítulo 2).
  • Saber que casi todo este capítulo funciona con el plan gratuito; aviso donde no.

Al terminar tendrás reglas de WAF propias, el login limitado por IP, Turnstile en los formularios públicos, el panel interno detrás de Access y una lista de lo que revisar en tu propio código.

Código completo en GitHub →

Las capas de protección

flowchart LR
    U((Petición)) --> DD[DDoS<br/>automático]
    DD --> WAF[WAF<br/>reglas gestionadas y propias]
    WAF --> RL[Limitación<br/>de peticiones]
    RL --> AC{¿/admin?}
    AC -->|Sí| ACC[Access<br/>login del equipo]
    AC -->|No| W[Worker]
    ACC --> W
    W --> T[Turnstile<br/>en formularios]
    W --> AU[Tu autenticación<br/>sesiones, permisos]
Capa Qué para Coste
DDoS Ataques de volumen Incluido siempre
WAF gestionado Ataques conocidos (inyección, vulnerabilidades famosas) Conjunto gratuito en Free; el completo, en planes de pago
Reglas propias Rutas, países o agentes que no quieres Unas pocas en Free
Limitación de peticiones Fuerza bruta, abuso de la API Regla de zona o binding en el Worker
Turnstile Bots en formularios Gratis
Access Acceso a paneles y entornos internos Gratis hasta 50 usuarios

Reglas de WAF propias

En Security › WAF › Custom rules, las reglas se escriben con el lenguaje de expresiones de Cloudflare. Estas son las que pongo en casi todos los proyectos:

Bloquear rutas que mi app no tiene
(http.request.uri.path contains "/wp-") or
(http.request.uri.path contains ".php") or
(http.request.uri.path eq "/.env")
Desafío en el registro si la petición viene de fuera de los países donde tengo usuarios
(http.request.uri.path eq "/api/signup" and
not ip.src.country in {"ES" "MX" "AR" "CO" "CL"})

Limitar peticiones desde el Worker

El binding de Rate Limiting cuenta peticiones por la clave que tú elijas (usuario, IP, clave de API) en cada ubicación de Cloudflare:

wrangler.jsonc (extracto)
{
"ratelimits": [
{
"name": "LOGIN_LIMITER",
"namespace_id": "1001",
"simple": { "limit": 5, "period": 60 }
}
]
}
src/index.ts (extracto)
app.post("/api/login", async (c) => {
const ip = c.req.header("cf-connecting-ip") ?? "unknown";
const { success } = await c.env.LOGIN_LIMITER.limit({ key: ip });
if (!success) {
return c.json({ error: "Too many attempts. Wait a minute." }, 429);
}
// …check the user and password
return c.json({ ok: true });
});

Turnstile en los formularios

Turnstile comprueba que hay una persona sin pedirle que identifique semáforos. Se crea un widget en Turnstile › Add widget, que da una clave pública (para el HTML) y una secreta (para el Worker).

Cómo viaja el token, del navegador a tu Worker:

sequenceDiagram
    participant N as Navegador
    participant T as Turnstile
    participant W as Tu Worker
    N->>T: El widget comprueba al visitante
    T-->>N: Token de un solo uso
    N->>W: Formulario y token
    W->>T: siteverify con tu clave secreta
    T-->>W: success: true o false
    W-->>N: Cuenta creada o error 400
Formulario de registro
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
<form method="post" action="/api/signup">
<input type="email" name="email" required />
<div class="cf-turnstile" data-sitekey="0x4AAAAAAA…"></div>
<button type="submit">Create account</button>
</form>
src/turnstile.ts
interface TurnstileResponse {
success: boolean;
"error-codes": string[];
}
export async function isHuman(
env: Env,
token: string | null,
ip: string | undefined,
) {
if (!token) return false;
const form = new FormData();
form.append("secret", env.TURNSTILE_SECRET);
form.append("response", token);
if (ip) form.append("remoteip", ip);
const res = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{ method: "POST", body: form },
);
const r = await res.json<TurnstileResponse>();
return r.success;
}
src/index.ts (extracto)
app.post("/api/signup", async (c) => {
const form = await c.req.formData();
const token = form.get("cf-turnstile-response");
const ok = await isHuman(
c.env,
typeof token === "string" ? token : null,
c.req.header("cf-connecting-ip"),
);
if (!ok) return c.json({ error: "We couldn't verify you" }, 400);
// …create the account
return c.json({ ok: true }, 201);
});

Access: el login de tu equipo delante del panel

Para paneles de administración, herramientas internas o entornos de pruebas, no hace falta programar un login: Access lo pone delante. En Zero Trust › Access › Applications creas una aplicación para admin.mydomain.dev (o la ruta /admin) con una política, por ejemplo, “correos que terminan en @mydomain.dev” o “estos tres correos”, con código por email, Google o GitHub.

Access añade a cada petición un JWT firmado. El Worker debe comprobarlo, por si alguien llega por otra ruta:

Terminal
npm install jose
src/access.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
export async function getAccessUser(req: Request, env: Env) {
const token = req.headers.get("cf-access-jwt-assertion");
if (!token) return null;
const team = `https://${env.ACCESS_TEAM}.cloudflareaccess.com`;
const keys = createRemoteJWKSet(new URL(`${team}/cdn-cgi/access/certs`));
try {
const { payload } = await jwtVerify(token, keys, {
issuer: team,
audience: env.ACCESS_AUD,
});
return typeof payload.email === "string" ? payload.email : null;
} catch {
return null;
}
}

ACCESS_AUD es la etiqueta Application Audience de la aplicación en Access. En el capítulo de entornos uso lo mismo para proteger las URLs de vista previa.

Seguridad de la propia aplicación

Cloudflare no sustituye a una buena autenticación. Lo mínimo que reviso:

Tema Qué hago
Sesiones Cookies HttpOnly, Secure, SameSite=Lax; nunca tokens en localStorage
Contraseñas Mejor no guardarlas: login con enlace por email o con un proveedor (GitHub, Google). Si las guardas, un algoritmo lento pensado para ello
Autorización Cada consulta filtra por el usuario de la sesión, también en R2 y Vectorize
CORS Solo los orígenes que necesitas; nunca * con credenciales
Cabeceras Content-Security-Policy, X-Content-Type-Options, Referrer-Policy
Secretos Siempre con wrangler secret put y rotación si alguno se expone

Cuánto cuesta

Cuánto cuesta al mes

Supuestos: WAF gratuito, Turnstile en dos formularios, limitación en el login y Access para 3 personas

ConceptoImporte
DDoS y WAF gratuitoIncluidos en el plan Free del dominio0,00 $
TurnstileGratis0,00 $
Rate Limiting (binding)Sin coste aparte0,00 $
AccessGratis hasta 50 usuarios0,00 $
Total al mes0,00 $

Precios consultados el 3 de noviembre de 2026. Pueden cambiar.

Resumen

  • Deja el WAF gratuito activo y añade reglas propias empezando en modo Log.
  • Limitación de peticiones con el binding para login y APIs; Turnstile con validación en el servidor.
  • Access para todo lo interno, y comprueba su JWT en el Worker.
  • Siguiente capítulo: observabilidad y alertas.

Recursos

¿Te ha servido? Esto forma parte de un libro que estoy escribiendo: Cloudflare de cero a producción. Ver el índice provisional →