Cache-Control mal configurado en endpoints autenticados: un bug sutil en NestJS

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

  1. El endpoint es autenticado
  2. 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:

  1. Caché compartido con datos incorrectos
  2. Respuestas obsoletas
  3. Requests que no llegan al backend
  4. 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

  1. public + Authorization es casi siempre un error
  2. Si un bug “desaparece” con ?rnd=timestamp, piensa en caché
  3. Si una request no llega al backend, revisa headers antes que lógica
  4. Vary no es opcional: es parte del contrato HTTP
  5. 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.

Jack Fiallos

Jack Fiallos

Te gustó este artículo?