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.
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:
(http.request.uri.path contains "/wp-") or(http.request.uri.path contains ".php") or(http.request.uri.path eq "/.env")(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:
{ "ratelimits": [ { "name": "LOGIN_LIMITER", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } } ]}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
<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>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;}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:
npm install joseimport { 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
| Concepto | Importe |
|---|---|
| DDoS y WAF gratuitoIncluidos en el plan Free del dominio | 0,00 $ |
| TurnstileGratis | 0,00 $ |
| Rate Limiting (binding)Sin coste aparte | 0,00 $ |
| AccessGratis hasta 50 usuarios | 0,00 $ |
| Total al mes | 0,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.
