Validar la identidad en el Worker: el JWT que inyecta Access
Access firma un testimonio y lo entrega a tu Worker en una cabecera y una cookie. Esta lección disecciona ese JWT claim por claim, explica por qué verificarlo dentro del Worker no es desconfianza redundante sino la única frontera que sobrevive a un despliegue mal cerrado, muestra la verificación de firma contra el JWKS del equipo con jose y el coste real de mantenerlo en caché entre isolates, y termina en la parte que nadie automatiza por ti: convertir un sujeto ya autenticado en un usuario de tu dominio sin apoyarse en el correo electrónico.
La petición ha cruzado Access, ha pasado por el proveedor de identidad y ahora sí llega a tu código. Trae consigo un testimonio firmado que dice quién es quien llama. Aquí empieza la parte que sí es tuya, y empieza con una pregunta que suena paranoica y no lo es: si el borde ya comprobó la identidad, por qué habría de comprobarla otra vez el Worker. La respuesta corta es que la comprobación del borde protege un camino, y tu Worker puede ser alcanzable por varios. La respuesta larga ocupa esta lección, y pasa por entender qué contiene exactamente ese JWT, qué afirma cada uno de sus campos, cómo se verifica una firma en el edge sin arruinar la latencia, y por qué el campo más importante de todos no es el correo electrónico sino la audiencia.
- Leer el
JWTdeAccesscampo a campo y saber qué afirma y qué no afirma cada uno. - Argumentar por qué la verificación dentro del Worker es la frontera que de verdad sobrevive.
- Verificar firma, emisor, audiencia y vigencia contra el
JWKSdel equipo con coste acotado. - Convertir un sujeto autenticado en un usuario de tu dominio sin depender del correo.
Qué llega y qué afirma
Access entrega el testimonio por dos vías simultáneas: la cookie CF_Authorization, que sirve al navegador para no repetir el inicio de sesión, y la cabecera Cf-Access-Jwt-Assertion, que viaja hacia el origen y es la que tu Worker debe leer. El contenido es un JWT firmado con RS256, y sus campos no son adornos: cada uno acota una dimensión distinta de la afirmación.
| Campo | Qué afirma | Riesgo de ignorarlo |
|---|---|---|
aud |
Para qué aplicación se emitió | Un testimonio de otra app entra en la tuya |
iss |
Qué organización lo firmó | Aceptas emisores ajenos |
sub |
Identificador estable del sujeto | Vinculas por correo | dato mutable |
exp y iat |
Ventana de vigencia | Aceptas sesiones caducadas |
type |
Testimonio de aplicación u organización | Confundes sesión global con permiso local |
common_name |
Presente solo con service token |
No distingues persona de máquina |
El campo aud es el que casi todo el mundo pasa por alto y el único que no se puede sustituir por nada. Contiene el AUD tag de la aplicación concreta, ese identificador que la lección anterior presentó como inmutable. Si tu verificación comprueba firma, emisor y vigencia pero no audiencia, cualquier persona con acceso legítimo a cualquier otra aplicación de la misma organización tiene un testimonio válido para la tuya. Es la escalada más silenciosa que existe en este modelo, porque no hay nada roto: la firma es correcta, el emisor es el bueno, la sesión está viva y la persona es real. Simplemente estaba autorizada a otra cosa.
El campo type distingue dos testimonios que conviven y se confunden con facilidad. El de aplicación se emite para una aplicación concreta y lleva su audiencia; el de organización representa la sesión global del usuario en el equipo y sirve al lanzador de aplicaciones y a los flujos comunes. Aceptar un testimonio de organización donde esperabas uno de aplicación equivale a aceptar que alguien ha iniciado sesión en algún sitio de la casa como prueba de que puede entrar en esta habitación.
Hay también un campo poco documentado y muy útil, identity_nonce, que identifica la sesión de identidad concreta. Es lo que permite, cuando hace falta, contrastar que la identidad sigue viva consultando el punto final de identidad en lugar de fiarse únicamente de la fecha de vencimiento. En la mayoría de las aplicaciones no compensa esa subpetición; en las que manejan operaciones irreversibles, sí.
El campo sub merece una nota aparte. Es el identificador estable del sujeto dentro de la organización, y es lo que debes usar como clave foránea hacia tu propia tabla de usuarios. El correo electrónico parece más cómodo y es una trampa a medio plazo: la gente cambia de apellido, las empresas cambian de dominio, los alias se reasignan, y el día que un correo antiguo se reasigne a otra persona tu base de datos habrá cambiado de dueño una fila sin que nadie ejecute una migración. En los testimonios emitidos para un service token el sujeto viene vacío y aparece en su lugar common_name, que es como distingues una máquina de una persona sin adivinar.
Por qué verificar lo que el borde ya verificó
Existe una cabecera de conveniencia, Cf-Access-Authenticated-User-Email, que trae el correo ya extraído y tienta a leerla y seguir adelante. Conviene entender por qué esa tentación es exactamente el fallo que este nivel trata de evitar. Esa cabecera es texto plano: su valor es de fiar únicamente si la petición entró por el proxy con Access delante, y esa condición es una propiedad del despliegue, no de la petición. Tu código no puede comprobarla; el JWT, en cambio, la lleva demostrada dentro.
Partir el JWT por los puntos y aplicar una decodificación de base64 al segundo trozo devuelve un objeto con campos de aspecto impecable. Eso no es verificación: es leer lo que el remitente quiso escribir. Sin comprobar la firma contra la clave pública del emisor, el contenido de un JWT tiene exactamente el mismo valor probatorio que un parámetro de la cadena de consulta.
Los caminos por los que una petición puede llegar a tu Worker sin haber pasado por Access son más de los que la intuición sugiere. Está el subdominio de workers.dev que quedó activo. Está la política de Bypass que alguien añadió para una integración y que, por definición, no emite testimonio. Está el service binding desde otro Worker de tu cuenta, que entrega la petición directamente al isolate destino y le pasa las cabeceras que el llamante decidió poner. Y está el día, dentro de dos años, en que otra persona añada una ruta nueva a la misma aplicación y no repare en que la protección era una propiedad del nombre de anfitrión y no del código.
Hay además un argumento de ciclo de vida que no depende de ningún fallo. La configuración de Access vive en un panel o en un repositorio de infraestructura, y tu código vive en otro; los dos evolucionan a ritmos distintos, los revisan personas distintas y se despliegan por tuberías distintas. Una garantía que depende de que dos artefactos separados estén de acuerdo es una garantía que caduca en la primera divergencia, y las divergencias no se anuncian. La verificación dentro del Worker es la única cuya vigencia se puede comprobar con una prueba automatizada en el mismo repositorio que la implementa.
La verificación dentro del Worker convierte todos esos escenarios en el mismo escenario: sin testimonio válido no hay acceso, venga la petición por donde venga. Esa es la definición operativa de defensa en profundidad, y su valor no está en desconfiar del borde, que es de fiar, sino en dejar de depender de una configuración que no controla el mismo repositorio que el código.
Verificar la firma sin arruinar la latencia
Las claves públicas del equipo se publican como un conjunto JWKS en la ruta cdn-cgi/access/certs del dominio de la organización. Cloudflare rota esas claves de forma periódica y publica varias a la vez durante la transición, de modo que un cliente que las recupere con cierta frecuencia nunca se queda sin poder validar. La biblioteca jose funciona sobre WebCrypto y por tanto corre nativa en Workers, sin necesidad de compatibilidad con Node.
import { createRemoteJWKSet, jwtVerify } from "jose";
const EQUIPO = "miequipo";
const AUD = "b1c2...tu-aud-tag";
const EMISOR = `https://${EQUIPO}.cloudflareaccess.com`;
const claves = createRemoteJWKSet(new URL(`${EMISOR}/cdn-cgi/access/certs`), {
cooldownDuration: 30_000,
cacheMaxAge: 600_000,
});
export async function identidad(request: Request) {
const token = request.headers.get("Cf-Access-Jwt-Assertion");
if (!token) return null;
const { payload } = await jwtVerify(token, claves, {
issuer: EMISOR,
audience: AUD,
clockTolerance: 30,
});
if (payload.type === "org") return null;
return payload as { sub: string; email?: string; common_name?: string };
}
Hay tres decisiones de coste escondidas en ese fragmento. La primera es que el conjunto de claves se declara en el ámbito del módulo, no dentro del manejador: así vive mientras viva el isolate y se reutiliza entre peticiones sucesivas, que es el mecanismo de caché más barato que existe en esta plataforma. La segunda es el periodo de enfriamiento, que impide que una avalancha de testimonios con una clave desconocida se traduzca en una avalancha de peticiones al servidor de claves. La tercera es la tolerancia de reloj, que absorbe la deriva de unos segundos sin abrir una ventana apreciable.
Queda un cuarto nivel de caché que merece la pena cuando el tráfico se reparte entre muchos centros de datos, porque cada uno arranca isolates fríos con el módulo recién evaluado y su conjunto de claves vacío. Guardar el JWKS en KV con un vencimiento corto convierte esa primera obtención en una lectura de caché regional, y refrescarla dentro de ctx.waitUntil mantiene la latencia del camino crítico intacta.
async function clavesEnKv(env: Env, ctx: ExecutionContext) {
const guardado = await env.CACHE.get("access-jwks", "json");
if (guardado) return guardado;
const respuesta = await fetch(`${EMISOR}/cdn-cgi/access/certs`);
const jwks = await respuesta.json();
ctx.waitUntil(env.CACHE.put("access-jwks", JSON.stringify(jwks), { expirationTtl: 3600 }));
return jwks;
}
Es una optimización, no un requisito, y como toda optimización tiene su contrapartida: un vencimiento demasiado largo puede dejarte con un conjunto de claves obsoleto justo durante una rotación. La tolerancia de una hora está bien calibrada frente a un ciclo de rotación de semanas, y en cualquier caso el error correcto ante una clave desconocida es reintentar la obtención una vez antes de rechazar, nunca aceptar sin verificar.
Un cliente automático que use service token recibe el testimonio en la cabecera y no gestiona cookies. Un navegador tiene ambas cosas. Leer siempre la cabecera y recurrir a la cookie solo como alternativa hace que el mismo código sirva para los dos casos sin ramificaciones.
flowchart TD P[peticion en el Worker] --> H[lee la cabecera del testimonio] H --> F[falta el testimonio] F --> D[responde 403 sin redirigir] H --> V[verifica firma contra el JWKS] V --> E[emisor y audiencia y vigencia] E --> M[mapea sub a usuario propio] M --> A[autoriza segun tu modelo de datos] E --> D
Del sujeto autenticado al usuario de tu dominio
Con el testimonio verificado tienes un hecho sólido y todavía insuficiente: sabes quién llama. Falta lo que ninguna capa externa puede resolver por ti, que es qué puede hacer aquí dentro. La traducción se hace en dos pasos y ambos tienen trampa.
El primero es el aprovisionamiento. La primera vez que un sujeto verificado aparece, tu base de datos no lo conoce. Crear la fila en ese momento, indexada por sub, es el patrón habitual y es correcto siempre que la creación sea idempotente y no conceda por sí misma ningún permiso: la existencia del usuario y sus capacidades son dos cosas distintas, y mezclarlas convierte a Access en tu sistema de autorización sin que nadie lo haya decidido.
Conviene además separar dos identificadores que la prisa tiende a fundir: el sujeto del proveedor y la clave primaria de tu tabla. Guardar el sub en una columna con índice único y usar tu propio identificador en el resto del modelo cuesta una unión más y te ahorra una migración el día que cambies de proveedor de identidad, que es un día que llega antes de lo que nadie prevé. La misma columna admite además varios valores por persona si algún día conviven dos proveedores.
El segundo es el de los grupos. Suele apetecer autorizar por pertenencia al directorio, y para eso el grupo tiene que llegar hasta aquí. Hay dos rutas: activar en la configuración de la aplicación la inclusión de los claims del proveedor de identidad, con lo que los grupos viajan dentro del propio JWT y no cuestan nada; o consultar el punto final de identidad de Access con la cookie de la sesión, lo que devuelve la identidad completa a cambio de una subpetición por cada llamada. La primera es casi siempre la buena, con el matiz de que engorda el testimonio y de que la pertenencia queda congelada hasta que la sesión se renueve.
Con las dos piezas colocadas, el manejador queda reducido a una forma que conviene reconocer porque es la misma en cualquier aplicación bien separada: verificar, resolver el sujeto y delegar en la lógica de negocio, que a partir de ese punto ya no vuelve a hablar de tokens ni de cabeceras.
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const claims = await identidad(request);
if (!claims) return new Response("no autorizado", { status: 403 });
const usuario = await resolverUsuario(env, claims.sub ?? claims.common_name!);
return manejar(request, env, usuario);
},
} satisfies ExportedHandler<Env>;
Fíjate en que la función de negocio recibe un usuario del dominio y no un JWT. Esa frontera hace que la autorización sea comprobable con pruebas normales, sin fabricar tokens ni levantar proveedores de identidad falsos, y es lo que impide que las decisiones de permisos se acaben dispersando por comprobaciones de cabeceras repartidas por todo el código.
Cuando falte o no valide el testimonio, devuelve un error de autorización y nada más. La redirección al inicio de sesión ya la orquesta Access en el borde, y montar una segunda en el Worker produce bucles imposibles de depurar en cuanto interviene un service token, que no sabe seguir redirecciones.
Aquí conviene detenerse en algo que va mucho más allá de esta plataforma, porque es un error epistemológico que la ingeniería repite con cada formato de credencial que inventa. Cuando abres un JWT en el depurador ves un objeto con un correo, un identificador y una fecha. Tu cerebro lo lee como lee un registro de base de datos: datos que están ahí. Y no lo están. Ese objeto llegó dentro de una petición que escribió un tercero, y por tanto es entrada controlada por el remitente, exactamente igual que un parámetro de formulario. Lo único que lo distingue de un formulario es que tiene la forma de algo autorizado, y esa forma es precisamente lo que lo hace peligroso, porque la apariencia de autoridad desactiva la sospecha en el momento en que más falta hace. La firma no está dentro del token en el sentido de que lo haga verdadero al llegar; la firma es una invitación a hacer un cálculo, y hasta que ese cálculo no se ejecuta, la afirmación no tiene ningún estatuto. Verificar no es comprobar que algo verdadero sigue siéndolo: verificar es la operación que fabrica la verdad de esa afirmación para tu proceso, y es rigurosamente la única. De ahí se sigue la consecuencia que reordena el diseño entero de un sistema, y es que la frontera de confianza no está donde nace el dato ni donde entra en tu red, sino en la línea de código exacta donde se ejecuta la comprobación. Todo lo que ocurre antes de esa línea, por muy dentro que parezca estar, es territorio del atacante; todo lo que ocurre después está en el tuyo. Por eso este oficio tiene una regla que no admite excepción y que conviene llevar escrita: si en una ruta de tu código el token entra y no se verifica, esa ruta no está protegida por nada, aunque delante haya un proxy, un cortafuegos y un proveedor de identidad de primera línea. La protección no es una propiedad de la topología, ni del contrato con el proveedor, ni de la buena voluntad de quien desplegó: es una propiedad de que la comprobación se haya ejecutado, aquí, en esta petición, ahora.
- Implementa la verificación con
josecomprobando firma, emisor, audiencia y vigencia, y colocando el conjunto de claves en el ámbito del módulo. - Toma un testimonio válido de otra aplicación de tu organización y envíalo a esta. Debe rechazarse. Si pasa, tu comprobación de audiencia no existe.
- Fabrica un
JWTcon la carga útil correcta y firma inventada. Comprueba que tu código lo rechaza y que no lo rechaza por comparar cadenas. - Llama a tu Worker desde otro Worker mediante
service binding, inyectando a mano la cabecera de correo autenticado. Observa qué habría pasado si te fiaras de ella. - Mide la latencia de la primera petición en un centro de datos frío y compárala con la centésima. Explica de dónde sale la diferencia y decide si te compensa cachear el
JWKSenKV.