> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Cloudflare Zero Trust a fondo: Access, Gateway y el cliente Cloudflare One (antes WARP)
- URL: https://blog.devutilites.com/cloudflare-zero-trust-a-fondo/
- Published: 2026-10-03T03:14:00.000Z
- Updated: 2026-10-03T03:14:00.000Z
- Description: 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.
- Author: Luis Collantes
- Tags: Cloudflare, DevOps

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](https://blog.devutilites.com/cloudflare-tunnel-a-fondo/).

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](https://blog.devutilites.com/content/images/2026/10/cf-zero-trust-esquema.png)

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):

```json
{
  "$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:

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

```javascript
// 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: "luis@ejemplo.com", 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: "luis@ejemplo.com" }).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:

```text
$ 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":"luis@ejemplo.com","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ítica | Capa                  | Qué hace                                                 |
| -------- | --------------------- | -------------------------------------------------------- |
| DNS      | Resolución de nombres | Bloquea dominios antes de que se establezca la conexión  |
| Network  | Capa 4                | Cortafuegos por IP, puerto y protocolo                   |
| HTTP     | Capa 7                | Proxy 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

| Plan          | Precio                | Usuarios   | Retención de logs |
| ------------- | --------------------- | ---------- | ----------------- |
| Free          | 0 $                   | Hasta 50   | Hasta 24 horas    |
| Pay-as-you-go | 7 $ por usuario y mes | Sin límite | Hasta 30 días     |
| Contract      | A medida              | Sin límite | Hasta 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 Access                   | 500              |
| Tokens de servicio                       | 50               |
| Proveedores de identidad                 | 50               |
| Políticas DNS, Network o HTTP de Gateway | 500 de cada tipo |
| Ubicaciones DNS                          | 250              |

## 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](https://blog.devutilites.com/cloudflare-turnstile-a-fondo/) 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

- [Cloudflare a fondo](https://blog.devutilites.com/cloudflare-a-fondo/): la visión general de la plataforma.
- [Cloudflare Tunnel a fondo](https://blog.devutilites.com/cloudflare-tunnel-a-fondo/): el camino hasta tus aplicaciones privadas.
- [Cloudflare WAF y protección DDoS a fondo](https://blog.devutilites.com/cloudflare-waf-ddos-a-fondo/): la seguridad para las aplicaciones públicas.
- [Cloudflare DNS a fondo](https://blog.devutilites.com/cloudflare-dns-a-fondo/): cómo resuelve tu dominio.
- [Cloudflare Workers a fondo](https://blog.devutilites.com/cloudflare-workers-a-fondo/): el runtime del validador de tokens.