Cada validación de token golpeaba nuestro origen. Ahora el JWKS vive en el edge.
Para validar uno de nuestros JWT, un servicio obtiene dos documentos públicos del emisor: el documento de discovery en /.well-known/openid-configuration y el JWKS al que este apunta, es decir, el conjunto de claves públicas con las que se firman los tokens. Son las cosas más solicitadas y menos secretas que servimos. Son claves públicas, que son públicas por definición, y metadatos que cambian solo cuando rotamos. Y hasta hace poco, cada una de esas peticiones volvía hasta nuestro origen. Como somos multiinquilino, la cosa era peor de lo que esa frase da a entender.
El origen hacía un trabajo que no tenía ninguna razón de ser
Cada inquilino es su propio emisor, con su propio documento .well-known y su propio JWKS. Y cada parte confiante, cada servidor de recursos, cada SDK que valida un token descarga esos documentos: en un arranque en frío, cuando su propia caché de corta duración caduca, una vez por cada instancia de servicio. Multiplica los emisores por inquilino por los validadores por servicio por los fallos de caché y obtienes una manguera a presión de peticiones, todas ellas terminando en la aplicación, todas ellas por unos documentos que son idénticos byte a byte para cualquier solicitante y que solo cambian cuando rotamos una clave.
Ese es el perfil de manual de algo que debería estar cacheado: público, idéntico, que rara vez cambia. En cambio, la aplicación estaba generando claves públicas, de forma dinámica, en la ruta caliente de la validación de tokens de todos los demás. Las claves no tenían nada que hacer siendo computadas en cada petición.
Ponerlos en el edge
El cambio en sí es pequeño: poner cabeceras Cache-Control honestas en las respuestas de discovery y del JWKS para que Cloudflare las cachee en el edge. Ahora un validador llega al punto de presencia de Cloudflare más cercano, y el origen sirve cada documento aproximadamente una vez por PoP y por TTL, en lugar de una vez por validación. El documento de discovery es trivialmente cacheable: casi nunca cambia. El JWKS es en el que hay que pensar a fondo, porque el JWKS es el único documento cuya tarea entera consiste en cambiar justo cuando rotas una clave, y cachear algo que debe estar fresco en el único momento en que cambia es donde la gente se hace daño.
Cómo te muerde un JWKS cacheado
La rotación de claves sumada a un conjunto de claves cacheado es una trampa, y falla en la peor dirección posible. Rota a una nueva clave de firma, empieza a emitir tokens con ella, y un validador que obtuvo el JWKS antes de que apareciera la nueva clave estará sosteniendo una copia obsoleta. Llega un token que lleva el identificador de la nueva clave; el validador lo busca, no lo encuentra y rechaza el token. El token es perfectamente válido. La firma es real. El usuario no hizo nada malo. Tu propia caché de edge acaba de devolver un 401 a un inicio de sesión legítimo.
Fíjate en qué dirección falla. Una clave privada obsoleta no es ningún acontecimiento: simplemente todavía no has empezado a usar la nueva. Un conjunto de claves públicas obsoleto es una caída autoinfligida, y la caché que la provocó la introdujiste a propósito, para ahorrar tráfico al origen. La seguridad de todo el esquema vive o muere en la rotación.
El patrón a prueba de rotaciones
La seguridad reside por completo en el orden, y el orden es lo contrario de un intercambio atómico. Tres reglas:
- Publica antes de firmar. La nueva clave entra primero en el JWKS, y no firmas ni un solo token con ella hasta que ese JWKS —con el nuevo identificador de clave incluido— lleve activo en el edge al menos un TTL de caché completo. Para cuando cualquier token que lleve la nueva clave pueda llegar a un validador, el conjunto de claves cacheado del validador ya contiene la clave.
- Retira tras la expiración, no en la rotación. La clave antigua permanece publicada en el JWKS hasta que haya caducado el último token que llegó a firmar. La antigua y la nueva coexisten durante todo el solapamiento. Retira la clave antigua en el momento en que rotas y dejarás abandonado cada token que firmó y que aún sea válido.
- TTL más corto que el solapamiento. El
max-agede la caché de edge, más un margen para el desfase de reloj, debe ser más corto que la ventana de solapamiento de claves. A la caché se le permite estar obsoleta; lo que nunca se le permite es estar obsoleta durante más tiempo que la ventana en la que ambas claves son válidas. Esa única desigualdad es lo que vuelve seguro un JWKS cacheado de forma agresiva.
En conjunto, la rotación se convierte en un fundido cruzado con un orden de ejecución fijo: añade la nueva clave, espera un TTL a que el edge y todos los validadores la vean, empieza a firmar con ella, espera a que los tokens antiguos envejezcan y expiren, y entonces retira la clave antigua. Cada paso está a salvo frente a una caché que va un TTL por detrás, porque el TTL se eligió para que fuera más pequeño que cualquier ventana en la que una caché pudiera ir por detrás.
La lección, factorizada
Cachear suele ser una decisión de latencia. Cachear claves públicas es una decisión de corrección, porque lo que estás cacheando es lo que decide qué tokens son reales. Puedes ponerlo perfectamente en el edge —deberías, porque es público y está muy solicitado—, pero solo una vez que la rotación se exprese como un solapamiento ordenado en lugar de como un intercambio, y solo una vez que se pueda demostrar que el TTL de la caché es más corto que ese solapamiento. Hazlo bien y los documentos más solicitados y más cacheables de tu sistema de autenticación dejarán de tocar tu origen, y nadie notará jamás la diferencia, que es precisamente el objetivo.