Cache-Control mal configurado en endpoints autenticados: un bug sutil en NestJS
Hay bugs que se explican rápido y bugs que te hacen dudar de todo: de tu lógica, de tu base de datos, de tu cliente móvil… y hasta de tu propia cordura.
Este artículo es sobre uno de esos últimos.
Hace un par de días estuve investigando una inconsistencia muy extraña en un endpoint autenticado. El mismo request, con el mismo token, devolvía respuestas diferentes dependiendo de si lo hacía desde un cliente REST o desde una aplicación móvil. Lo peor: desde el móvil ni siquiera veía la petición llegar al backend, lo que hacía imposible el debugging tradicional.
El problema no estaba en la lógica de negocio. Estaba en algo mucho más sutil: una mala configuración de caché HTTP.
El síntoma: respuestas distintas para el mismo endpoint
Imagina un endpoint autenticado que devuelve información relacionada con fechas disponibles para un usuario:
GET /api/calendar/available-dates
Authorization: Bearer <token>
x-iana-timezone: Europe/Dublin
x-iso-lang: en
Desde un cliente REST (Insomnia, Postman), la respuesta era:
{
"success": true,
"data": {
"months": {
"jan": [2, 3, 4, 7],
"feb": [],
"mar": []
}
}
}
Desde la app móvil, el mismo endpoint devolvía:
{
"success": true,
"data": {
"months": {
"jan": [2, 3, 4],
"feb": [],
"mar": []
}
}
}
La diferencia era mínima… pero real. Y lo más inquietante: cuando activaba logs detallados en el backend, las peticiones del móvil no aparecían.
Primeras sospechas (y falsos culpables)
Como suele pasar, lo primero que uno sospecha es:
- diferencias de token
- errores de sincronización en la base de datos
- lógica condicional por timezone
- bugs en el cliente móvil
Nada de eso era el problema.
El dato clave apareció al inspeccionar los headers de la respuesta HTTP.
La pista definitiva: Cache-Control: public
El endpoint estaba configurado así (simplificado):
@Header('Cache-Control', 'public, max-age=86400')
@Header('Vary', 'Accept')
A primera vista, parece inofensivo. Pero hay dos errores graves aquí:
- El endpoint es autenticado
- La respuesta depende del usuario (token, timezone, idioma)
Y aun así, se estaba marcando como public.
Por qué esto es un problema serio
1. Cachés compartidos
Cuando un endpoint responde con:
Cache-Control: public
estás permitiendo que:
- proxies
- CDNs
- stacks HTTP nativos (especialmente en móvil)
reutilicen esa respuesta para futuras peticiones, incluso si vienen con otro token.
Aunque en tu entorno actual no tengas un CDN, el cliente móvil ya actúa como una capa de caché.
2. Respuestas obsoletas (stale data)
Con max-age=86400, una respuesta puede reutilizarse durante 24 horas sin tocar el backend.
Eso explica perfectamente:
- por qué el móvil devolvía datos antiguos
- por qué el backend no veía la petición
- por qué agregar un query param aleatorio (
?rnd=...) “arreglaba” el problema
3. Riesgo de seguridad
Este es el punto más delicado.
Un endpoint autenticado no debería nunca ser cacheado públicamente, salvo casos muy controlados. En el peor escenario, una cache compartida podría servir datos de un usuario a otro.
Aunque no llegue a ocurrir, la configuración ya es incorrecta desde el punto de vista de seguridad.
El root cause
Endpoints autenticados usando Cache-Control: public sin variar por Authorization.
Eso provoca:
- Caché compartido con datos incorrectos
- Respuestas obsoletas
- Requests que no llegan al backend
- Debugging prácticamente imposible
La solución correcta en NestJS
Después de analizar el caso, decidí aplicar esta política:
Antes
@Header('Cache-Control', 'public, max-age=86400')
@Header('Vary', 'Accept')
Después (la forma correcta)
@Header('Cache-Control', 'private, max-age=180')
@Header('Vary', 'Accept, Authorization, x-iana-timezone, x-iso-lang')
Por qué he decidido esta solución?
private
Indica que solo el cliente puede cachear la respuesta. Ningún proxy o cache compartido debería reutilizarla para otros usuarios.
max-age=180
Permite una caché corta (3 minutos), suficiente para mejorar performance sin introducir inconsistencias visibles.
Vary: Authorization
Asegura que la respuesta se cachea por usuario, no de forma global.
Vary: x-iana-timezone, x-iso-lang
Clave cuando:
- trabajas con fechas locales
- agrupas información por día/mes
- devuelves contenido dependiente del idioma
Si un header afecta la respuesta, debe estar en Vary.
¿Por qué no usar simplemente no-store?
También es una opción válida y los chats de AI me lo recomendaron, especialmente para endpoints muy sensibles, pero necesito algo de cache en el cliente para liberar mi servidor de peticiones. Tampoco es que 3 minutos hagan mucha diferencia.
Cache-Control: no-store
Yo lo uso para:
- contadores
- notificaciones
- datos “en tiempo real”
- información que, si se desincroniza, el usuario lo nota de inmediato
Pero en este caso, private + max-age corto era un buen equilibrio.
Lecciones aprendidas
public+Authorizationes casi siempre un error- Si un bug “desaparece” con
?rnd=timestamp, piensa en caché - Si una request no llega al backend, revisa headers antes que lógica
Varyno es opcional: es parte del contrato HTTP- El caché mal configurado no solo rompe datos, rompe el debugging
Conclusión
Este no fue un bug de NestJS, ni de la base de datos, ni del cliente móvil. Fue un recordatorio de que HTTP sigue siendo parte fundamental de nuestra arquitectura, incluso cuando usamos frameworks modernos.
Una línea mal puesta en Cache-Control puede generar bugs sutiles, inconsistentes y peligrosos.
Y lo peor: bugs que parecen “fantasmas”.
Si estás construyendo APIs autenticadas, revisa hoy mismo tus headers. Te vas a ahorrar horas — o días — de frustración en el futuro.
Happy coding! :)
Photo by Stefano Pollio on Unsplash
Written with StackEdit.