Cloudflare Zero Trust a fondo: Access, Gateway y el cliente Cloudflare One (antes WARP)

Qué es Cloudflare Zero Trust y cómo encajan Access, Gateway, el cliente Cloudflare One (antes WARP) y Tunnel para sustituir a la VPN. Plan gratuito, límites, tokens de servicio y cómo validar el JWT de Access en un Worker, probado en local.

Portada: Cloudflare Zero Trust a fondo

El modelo clásico de seguridad era un castillo con foso: dentro de la red de la oficina (o conectado a la VPN) todo es de confianza; fuera, nada. Funciona hasta que alguien entra en la VPN con una contraseña robada y tiene acceso a todo. Zero Trust cambia la pregunta: en lugar de «¿estás dentro de la red?», cada petición a cada aplicación tiene que responder a «¿quién eres, desde qué dispositivo y tienes permiso para esto en concreto?».

Cloudflare Zero Trust, que forma parte de lo que Cloudflare llama ahora Cloudflare One, es su plataforma para hacer eso. Tiene tres piezas principales, más una que ya conoces:

  • Access: pone un control de identidad delante de tus aplicaciones (web, SSH, RDP…).
  • Gateway: filtra el tráfico de tus dispositivos hacia internet (DNS, red y HTTP).
  • Cliente Cloudflare One (hasta hace poco WARP): el programa que instalas en los portátiles y móviles para que su tráfico pase por Gateway y puedan llegar a tu red privada.
  • Tunnel, que conecta tus servidores y redes a Cloudflare sin abrir puertos. Lo vimos en su propio artículo.

En este artículo vemos cómo encajan, qué incluye el plan gratuito y cómo validar en tu aplicación el token que pone Access, con un Worker probado en local contra un emisor de tokens simulado.

🧪
El validador de tokens de Access se ha ejecutado de verdad el 3 de octubre de 2026 con Wrangler 4.147.0, jose 6.2.12 y Node.js 24.21.0. Como no he usado una cuenta de Zero Trust, he simulado la parte de Access con un pequeño servidor que publica las claves públicas y firma tokens con el mismo formato. Access, Gateway y el cliente Cloudflare One reales no los he probado. Planes, límites y comportamiento salen de la documentación oficial y de la página de planes de Cloudflare, revisadas ese mismo día.

Cómo encajan las piezas

Esquema de Cloudflare Zero Trust: los usuarios con el cliente Cloudflare One envían su tráfico a Cloudflare, donde Gateway filtra DNS, red y HTTP hacia internet; Access verifica identidad y dispositivo antes de dejar pasar a las aplicaciones; Tunnel conecta las aplicaciones y redes privadas sin abrir puertos
Gateway protege el tráfico de salida de tus usuarios; Access, la entrada a tus aplicaciones; Tunnel, el camino hasta ellas. Diagrama: devutilites.

Hay dos direcciones de tráfico:

  • Hacia tus aplicaciones: un usuario quiere entrar en grafana.ejemplo.com o en un servidor por SSH. Access le pide identificarse, comprueba tus políticas y, si pasa, deja que la petición siga por el túnel hasta el servicio.
  • Desde tus usuarios hacia internet: el cliente Cloudflare One manda el tráfico del dispositivo a Cloudflare, donde Gateway bloquea dominios maliciosos, categorías de contenido o lo que tú decidas.

Combinando las dos, sustituyes a la VPN: el portátil con el cliente llega a las aplicaciones y redes privadas que le permitas, y nada más.

Access

Access protege aplicaciones self-hosted (las tuyas, publicadas con un túnel o con un registro proxied), aplicaciones SaaS y acceso a infraestructura (SSH, RDP). Una aplicación de Access es un dominio o ruta más un conjunto de políticas:

  • Todas las aplicaciones deniegan por defecto: el usuario debe coincidir con una política Allow para entrar.
  • Las políticas pueden pedir un correo o un dominio concretos, pertenecer a un grupo del proveedor de identidad, un país, un dispositivo que cumpla ciertas condiciones (con el cliente instalado)...
  • Para identificarse sirven Google, GitHub, Microsoft Entra ID, Okta, cualquier SAML u OIDC, o un código de un solo uso por correo si no tienes proveedor.
  • Para máquinas (un script, un CI, Hyperdrive) existen los tokens de servicio: envían las cabeceras CF-Access-Client-Id y CF-Access-Client-Secret y se autorizan con una política Service Auth. Tienen fecha de caducidad (un año, 8760h, en el ejemplo de la documentación).

Cuando Access deja pasar una petición, añade la cabecera Cf-Access-Jwt-Assertion con un JWT firmado con las claves de tu cuenta. Contiene, entre otros, aud (la etiqueta de la aplicación), email, sub (el id del usuario), iss (tu dominio de equipo), country y las fechas iat, nbf y exp.

Por qué validar el JWT en tu aplicación

Si alguien encuentra una forma de llegar a tu origen sin pasar por Access (una IP expuesta, un registro DNS olvidado, otra regla mal puesta), lo único que te protege es que tu aplicación compruebe ese token. La documentación lo recomienda expresamente, y recomienda validar la cabecera en lugar de la cookie CF_Authorization.

Las claves públicas están en https://<tu-equipo>.cloudflareaccess.com/cdn-cgi/access/certs. Access las rota cada 6 semanas y la anterior sigue siendo válida 7 días, así que no copies la clave en tu código: descárgala de esa URL y elige la que corresponda al kid del token.

Validar el token en un Worker

La configuración necesita tu dominio de equipo y la AUD de la aplicación (en Additional settings de la aplicación de Access; no cambia salvo que borres y recrees la aplicación):

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "access-lab",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "vars": {
    // En producción: https://<tu-equipo>.cloudflareaccess.com
    "TEAM_DOMAIN": "https://miequipo.cloudflareaccess.com",
    // Application Audience (AUD) Tag de la aplicación de Access
    "POLICY_AUD": "32eafc7626e974616deaf0dc3ce63d7bcbed58a2731e84d06bc3cdf1b53c4228"
  }
}

El Worker sigue el ejemplo oficial con jose, añadiendo el algoritmo esperado y mensajes de error claros:

import { createRemoteJWKSet, jwtVerify, errors } from "jose";

export default {
  async fetch(request, env): Promise<Response> {
    // Access añade esta cabecera a cada petición que deja pasar
    const token = request.headers.get("Cf-Access-Jwt-Assertion");
    if (!token) return new Response("Falta el token de Access", { status: 403 });

    // Claves públicas de tu equipo. Se descargan de la URL (no las copies a mano:
    // Access las rota cada 6 semanas) y jose elige la que corresponde al `kid`.
    const JWKS = createRemoteJWKSet(new URL(`${env.TEAM_DOMAIN}/cdn-cgi/access/certs`));

    try {
      const { payload } = await jwtVerify(token, JWKS, {
        issuer: env.TEAM_DOMAIN, // lo emitió TU equipo
        audience: env.POLICY_AUD, // y para ESTA aplicación
        algorithms: ["RS256"],
      });
      return Response.json({ hola: payload.email, pais: payload.country, sub: payload.sub });
    } catch (e) {
      const motivo =
        e instanceof errors.JWTExpired ? "caducado"
        : e instanceof errors.JWTClaimValidationFailed ? `claim ${e.claim} no válido`
        : e instanceof errors.JWSSignatureVerificationFailed ? "firma no válida"
        : e instanceof errors.JWKSNoMatchingKey ? "ninguna clave coincide con el kid"
        : (e as Error).message;
      return new Response(`Token rechazado: ${motivo}`, { status: 403 });
    }
  },
} satisfies ExportedHandler<Env>;

Probarlo sin cuenta: un Access de mentira

Para probarlo en local escribí un servidor que hace de Access: genera un par de claves RSA, publica la pública en /cdn-cgi/access/certs y firma tokens con el mismo formato que los reales. Además genera tokens malos a propósito: para otra aplicación, caducado, firmado con otra clave y con un kid desconocido.

// Simula lo que hace Cloudflare Access para probar el Worker en local:
// publica un JWKS en /cdn-cgi/access/certs y firma tokens como los de Access.
import { createServer } from "node:http";
import { writeFileSync } from "node:fs";
import { generateKeyPair, exportJWK, SignJWT } from "jose";

const EQUIPO = "http://127.0.0.1:8898"; // hace de https://<equipo>.cloudflareaccess.com
const AUD = "32eafc7626e974616deaf0dc3ce63d7bcbed58a2731e84d06bc3cdf1b53c4228";

const buena = await generateKeyPair("RS256");
const impostora = await generateKeyPair("RS256");
const jwk = { ...(await exportJWK(buena.publicKey)), kid: "clave-actual", alg: "RS256", use: "sig" };

const firmar = (claims, clave = buena.privateKey, kid = "clave-actual", exp = "1m") =>
  new SignJWT({ email: "[email protected]", type: "app", country: "ES", ...claims })
    .setProtectedHeader({ alg: "RS256", kid })
    .setIssuer(EQUIPO)
    .setAudience([AUD])
    .setSubject("7335d417-61da-459d-899c-0a01c76a2f94")
    .setIssuedAt()
    .setExpirationTime(exp)
    .sign(clave);

writeFileSync("tokens.json", JSON.stringify({
  valido: await firmar({}),
  otraApp: await new SignJWT({ email: "[email protected]" }).setProtectedHeader({ alg: "RS256", kid: "clave-actual" })
    .setIssuer(EQUIPO).setAudience(["otra-aplicacion"]).setExpirationTime("1m").sign(buena.privateKey),
  caducado: await firmar({}, buena.privateKey, "clave-actual", Math.floor(Date.now() / 1000) - 60),
  falsificado: await firmar({}, impostora.privateKey),
  kidDesconocido: await firmar({}, impostora.privateKey, "clave-inventada"),
}, null, 2));

createServer((req, res) => {
  if (req.url === "/cdn-cgi/access/certs") {
    res.setHeader("content-type", "application/json");
    return res.end(JSON.stringify({ keys: [jwk] }));
  }
  res.statusCode = 404; res.end();
}).listen(8898, "127.0.0.1", () => console.log("JWKS en", `${EQUIPO}/cdn-cgi/access/certs`));

En .dev.vars, TEAM_DOMAIN=http://127.0.0.1:8898 apunta el Worker al simulador. El resultado:

$ curl -s localhost:8808/
Falta el token de Access (HTTP 403)
$ curl -s -H "Cf-Access-Jwt-Assertion: $(jq -r .valido tokens.json)" localhost:8808/
{"hola":"[email protected]","pais":"ES","sub":"7335d417-61da-459d-899c-0a01c76a2f94"} (HTTP 200)
$ curl -s -H "Cf-Access-Jwt-Assertion: $(jq -r .otraApp tokens.json)" localhost:8808/
Token rechazado: claim aud no válido (HTTP 403)
$ curl -s -H "Cf-Access-Jwt-Assertion: $(jq -r .caducado tokens.json)" localhost:8808/
Token rechazado: caducado (HTTP 403)
$ curl -s -H "Cf-Access-Jwt-Assertion: $(jq -r .falsificado tokens.json)" localhost:8808/
Token rechazado: firma no válida (HTTP 403)
$ curl -s -H "Cf-Access-Jwt-Assertion: $(jq -r .kidDesconocido tokens.json)" localhost:8808/
Token rechazado: ninguna clave coincide con el kid (HTTP 403)

Cada caso falla por la razón correcta. El que más importa es el de la audiencia: un token válido de tu equipo, pero emitido para otra aplicación, no debe abrir esta.

Gateway y el cliente Cloudflare One

Gateway filtra el tráfico saliente de tu organización con tres tipos de políticas:

PolíticaCapaQué hace
DNSResolución de nombresBloquea dominios antes de que se establezca la conexión
NetworkCapa 4Cortafuegos por IP, puerto y protocolo
HTTPCapa 7Proxy con inspección TLS: rutas, descargas, aplicaciones

Las políticas DNS son las más rápidas de desplegar: basta con que los dispositivos o la red de la oficina usen el resolvedor de Gateway, sin instalar nada. Para las de red y HTTP, lo habitual es usar el cliente (también hay otras vías, como ficheros PAC para el proxy HTTP).

El cliente Cloudflare One (antes WARP) establece dos conexiones cifradas desde el dispositivo: un túnel para el tráfico (WireGuard o MASQUE) y un proxy DNS por DNS over HTTPS. Además informa del estado del dispositivo (sistema actualizado, cifrado de disco…) para usarlo en las políticas de Access. Funciona en los sistemas operativos principales y se despliega con Intune, JAMF y similares.

Planes y límites

PlanPrecioUsuariosRetención de logs
Free0 $Hasta 50Hasta 24 horas
Pay-as-you-go7 $ por usuario y mesSin límiteHasta 30 días
ContractA medidaSin límiteHasta 6 meses

Cuenta como usuario (seat) quien se autentica en una aplicación de Access o registra un dispositivo con el cliente. Para un homelab o un equipo pequeño, el plan gratuito sobra.

Límite (por cuenta)Valor
Aplicaciones de Access500
Tokens de servicio50
Proveedores de identidad50
Políticas DNS, Network o HTTP de Gateway500 de cada tipo
Ubicaciones DNS250

Errores comunes (y cómo evitarlos)

  • No validar el JWT en el origen. Si se puede llegar al origen por otro camino, Access no sirve de nada. Valida, o haz que el origen solo sea accesible por un túnel.
  • Comprobar la firma pero no la audiencia. Cualquier token de tu equipo valdría para cualquier aplicación.
  • Copiar la clave pública en el código. Se rota cada 6 semanas. Usa la URL de certs y el kid.
  • Usar public_cert en lugar de public_certs. La documentación avisa de que una caché desactualizada puede darte la clave antigua; busca el kid en public_certs (o usa el JWKS de keys, como hace jose).
  • Políticas Bypass demasiado amplias. Bypass desactiva Access para quien coincida; úsalo solo para rutas realmente públicas.
  • Tokens de servicio sin caducidad controlada. Apunta cuándo caducan y rótalos antes.
  • Confiar en los claims personalizados para autorizar. Access los recorta si pasan de ~1 KB; para grupos grandes consulta /cdn-cgi/access/get-identity.

Cuándo usar Zero Trust (y cuándo no)

Úsalo para proteger paneles internos y herramientas de un homelab o una empresa pequeña sin VPN, para dar acceso a proveedores externos solo a una aplicación concreta, para filtrar DNS en los dispositivos de tu equipo y para sustituir una VPN completa combinándolo con Tunnel y el cliente.

No lo necesitas para una aplicación pública con su propio sistema de usuarios: ahí basta con tu autenticación (y Turnstile contra bots). Y la parte de Gateway con inspección TLS requiere instalar un certificado raíz en los dispositivos, algo que conviene pensar antes en equipos personales.

Sigue leyendo