Cookies con Astro.cookies: opciones y firma
El primitivo de bajo nivel bajo toda sesión. La API tipada Astro.cookies para leer con get y sus conversores, escribir con set y borrar con delete. Las opciones que deciden la seguridad de una cookie: httpOnly frente al robo por scripts, secure para exigir https, sameSite contra el CSRF y maxAge para acotar su vida. Y por qué una cookie que carga significado hay que firmarla con HMAC para detectar manipulación, con la frontera exacta entre integridad y secreto.
Bajo toda sesión hay una cookie, y conviene bajar hasta ese primitivo para entender qué se puede y qué no se debe hacer con él. Una cookie es un par clave-valor que el servidor pide al navegador que guarde y reenvíe en cada petición al mismo sitio. Astro.cookies es la interfaz tipada para manejarlas: leerlas, escribirlas, borrarlas y —esto es lo decisivo— configurarlas con las opciones que separan una cookie segura de un agujero. Porque una cookie viaja al cliente, y todo lo que viaja al cliente es visible y manipulable. Dominar Astro.cookies es, en el fondo, dominar una frontera de confianza.
- Leer cookies con
Astro.cookies.gety sus conversores a JSON, número y booleano. - Escribir y borrar con
Astro.cookies.setyAstro.cookies.delete. - Configurar las opciones de seguridad:
httpOnly,secure,sameSiteymaxAge. - Firmar una cookie con HMAC para detectar manipulación, sin confundir integridad con secreto.
Leer: get, has y los conversores
Astro.cookies.get devuelve un objeto AstroCookie —o undefined si la cookie no existe—. Ese objeto no es una cadena pelada: expone el valor crudo en .value y una batería de conversores que interpretan el texto y devuelven el tipo que esperas. Como una cookie siempre viaja como cadena, esos conversores te ahorran el parseo manual y su manejo de errores.
---
const tema = Astro.cookies.get('tema')?.value; // cadena | undefined
const prefs = Astro.cookies.get('prefs')?.json(); // objeto parseado de JSON
const visitas = Astro.cookies.get('visitas')?.number(); // numero
const activo = Astro.cookies.get('activo')?.boolean(); // booleano
const existe = Astro.cookies.has('sesion'); // true o false
---
Astro.cookies.has responde a una pregunta más barata —¿está?— sin materializar el valor. Importa un matiz: get y has miran tanto las cookies entrantes de la petición como las que tú mismo hayas fijado durante el renderizado, así que leen un estado coherente aunque acabes de escribir una cookie en la misma petición.
Escribir y borrar: set y delete
Astro.cookies.set acepta una clave, un valor y un objeto de opciones. El valor puede ser una cadena o un objeto; si le pasas un objeto, Astro lo serializa a JSON por ti, y get(...).json() lo recupera. Astro.cookies.delete caduca la cookie pidiéndole al navegador que la olvide.
Astro.cookies.set('tema', 'oscuro', { path: '/', maxAge: 60 * 60 * 24 * 365 });
Astro.cookies.set('prefs', { idioma: 'es', compacto: true }, { path: '/' });
Astro.cookies.delete('tema', { path: '/' });
Hay una trampa clásica con delete: para que el navegador borre la cookie correcta, las opciones de alcance —path y domain— deben coincidir con las que usaste al crearla. Una cookie fijada en path: '/app' no se borra con un delete sin ruta; quedaría viva, fantasma, reenviándose en cada petición bajo /app. La regla práctica es sencilla: escribe y borra siempre con el mismo path.
Conviene saber qué hace set por dentro: no habla con el navegador al instante, sino que registra la cookie para que Astro la emita como una cabecera Set-Cookie al construir la respuesta. Por eso puedes fijar una cookie en el frontmatter y leerla en esa misma petición —Astro la tiene en memoria— aunque el navegador todavía no la conozca. Si necesitas depurar, Astro.cookies.headers te deja inspeccionar las cabeceras Set-Cookie pendientes antes de que salgan.
El navegador impone límites duros: alrededor de cuatro kilobytes por cookie y unas pocas decenas de cookies por dominio. Y hay un coste que se olvida: cada cookie viaja en todas las peticiones al sitio, así que una cookie gorda encarece cada carga con bytes que suben por la red una y otra vez. Estos límites no son un estorbo, son un recordatorio de para qué sirve el mecanismo: llevar identificadores y banderas pequeñas, no datos. En cuanto pienses en guardar algo voluminoso, esa es justo la señal para mudarlo a una sesión.
Las opciones que deciden la seguridad
Las opciones de set no son adornos: son la diferencia entre una cookie defendida y una expuesta. Cuatro merecen memorizarse.
httpOnly
La cookie deja de ser legible por JavaScript del cliente. Corta el robo por scripts inyectados: un XSS ya no puede leer tu cookie de sesion.
secure
El navegador solo envia la cookie por https. Impide que viaje en claro por una red hostil.
sameSite
Con lax o strict, la cookie no acompana a peticiones nacidas en otro sitio. Es la defensa de primera linea contra el CSRF.
maxAge
Fija la vida de la cookie en segundos. Sin el, es de sesion y muere al cerrar el navegador; con el, caduca sola.
La combinación que querrás por defecto para cualquier cookie sensible es httpOnly: true, secure: true, sameSite: 'lax', con un maxAge acorde a cuánto debe durar. Hay opciones secundarias que conviene conocer sin obsesionarse. expires fija una fecha absoluta de caducidad y es la alternativa a maxAge —si das las dos, maxAge manda en los navegadores modernos—. domain ensancha el alcance de la cookie a los subdominios, un arma de doble filo: úsalo solo si de verdad compartes sesión entre app.midominio.com y api.midominio.com, porque ampliar el alcance amplía la superficie de exposición. Y partitioned aísla la cookie por sitio de origen en contextos de terceros, y exige secure: true para funcionar. Pero son las cuatro primeras las que fijan la postura de seguridad; las demás afinan alcance y vigencia.
Un endurecimiento poco conocido pero potente son los prefijos de nombre. Si bautizas una cookie con el prefijo __Host-, el navegador se niega a aceptarla salvo que venga con secure, sin domain y con path en la raíz —justo la configuración más restrictiva posible—. El prefijo __Secure- exige al menos secure. Como el prefijo forma parte del nombre, un atacante que intente sobrescribir la cookie desde un subdominio hermano o por http fracasa: es el propio navegador quien rechaza la versión mal configurada, sin que tu servidor tenga que hacer nada.
Poner el prefijo __Host- a tu cookie de sesión es de las mejores relaciones esfuerzo-beneficio en seguridad web: no cuesta más que elegir el nombre y blinda la cookie contra la fijación desde un subdominio vecino y contra su envío por una conexión sin cifrar. Si controlas el nombre de la cookie —y con la Sessions API lo haces en session.cookie—, aprovéchalo.
Es fácil confundir para qué sirve cada opción. httpOnly defiende del robo: aunque un atacante inyecte un script en tu página, no podrá leer la cookie. sameSite defiende del uso indebido: aunque el atacante no lea la cookie, sin sameSite podría lograr que el navegador la envíe en una petición forjada desde otro sitio, y actuar en tu nombre. Una protege la confidencialidad del valor; la otra, el contexto en que se usa. Necesitas las dos, porque tapan huecos diferentes.
Firmar: integridad para una cookie con significado
Llega el punto delicado. Si guardas en una cookie un dato que significa algo —rol=admin, userId=42—, el servidor estará confiando en un valor que el cliente controla y puede reescribir. La defensa no es esconderlo, es firmarlo: adjuntar una firma criptográfica, calculada con un secreto que solo el servidor conoce, que el servidor recomputa al leer para verificar que nadie tocó el dato. Astro no firma por ti —deja la política en tus manos—, pero compones la firma con la Web Crypto API estándar y la enchufas en la cookie.
// firma.ts — HMAC-SHA256 con la Web Crypto API
const clave = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(process.env.COOKIE_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const hex = (b: ArrayBuffer) =>
[...new Uint8Array(b)].map((n) => n.toString(16).padStart(2, '0')).join('');
export async function firmar(valor: string): Promise<string> {
const f = await crypto.subtle.sign('HMAC', clave, new TextEncoder().encode(valor));
return `${valor}.${hex(f)}`; // el valor y su firma, unidos
}
export async function verificar(firmado: string): Promise<string | null> {
const i = firmado.lastIndexOf('.');
const valor = firmado.slice(0, i);
return (await firmar(valor)) === firmado ? valor : null; // firma no valida -> null
}
Con ese par, escribes Astro.cookies.set('rol', await firmar('admin')) y, al leer, pasas el contenido por verificar: si devuelve null, alguien manipuló la cookie y la descartas. La frontera conceptual que no debes cruzar es esta: firmar da integridad, no secreto. El valor sigue siendo legible por cualquiera que abra la cookie —la firma solo prueba que no ha cambiado—. Si además necesitas que el contenido sea ilegible, tienes que cifrarlo, o mejor, no ponerlo en la cookie en absoluto y usar una sesión, donde la cookie lleva un id opaco y el dato vive en el servidor.
Astro te ofrece un gancho para que firmar no sea un ritual que haya que recordar en cada llamada: la opción encode de set transforma el valor justo antes de serializarlo, y su pareja decode en get lo revierte al leer. Enchufando ahí tus funciones, cada Astro.cookies.set firma sola y cada Astro.cookies.get verifica sola, y el resto de tu código trata la cookie como si fuera de confianza porque la frontera se aplica en un único punto.
El verificar del ejemplo usa === para comparar, y eso, en un sistema expuesto, tiene un matiz: una comparación de cadenas normal termina en cuanto encuentra el primer carácter distinto, y esa diferencia de tiempo —minúscula pero medible— filtra información sobre cuántos caracteres acertó un atacante que prueba firmas. La defensa canónica es comparar en tiempo constante, recorriendo siempre los dos valores enteros. Puedes lograrlo delegando en crypto.subtle.verify, que compara internamente sin fugas de tiempo, en lugar de recomputar y comparar cadenas a mano. Para la mayoría de proyectos el riesgo es teórico, pero saber que existe es parte de firmar bien.
flowchart LR
SET[servidor firma valor con secreto] --> CK[cookie valor mas firma]
CK --> NAV[navegador la reenvia]
NAV --> VER[servidor recomputa la firma]
VER --> OK{coincide}
OK -->|si| USE[dato de confianza]
OK -->|no| REJ[cookie manipulada se descarta]
style USE fill:#a6e3a1,color:#11111b
style REJ fill:#f38ba8,color:#11111bLa forma correcta de pensar una cookie es como un mensaje que confías a un mensajero que no controlas, para que te lo devuelva más tarde a través de un territorio lleno de adversarios. Esa imagen ordena de golpe todas las decisiones. Como el mensajero —el navegador— puede ser manipulado por scripts hostiles, marcas la cookie httpOnly para que el mensaje no pueda ser leído por manos ajenas. Como el camino —la red— puede tener espías, exiges secure para que solo viaje cifrado. Como un adversario puede engañar al mensajero para que entregue el mensaje en el sitio equivocado, pones sameSite para que solo lo lleve al destinatario legítimo. Y como el propio mensajero podría alterar el contenido, lo firmas con un sello que solo tú sabes reproducir, de modo que cualquier cambio se delate al recibirlo. Cada opción de una cookie es una respuesta a una pregunta sobre la confianza: quién puede leer esto, por dónde puede viajar, a quién se puede entregar, cómo sé que no lo han tocado. El error de fondo del principiante es tratar la cookie como si fuera memoria del servidor —un lugar seguro donde dejar cosas— cuando es exactamente lo contrario: es el único trozo de tu estado que abandona tu dominio y se adentra en el del usuario, que no es de fiar por definición. De ahí se sigue la jerarquía de decisiones que gobierna todo el diseño de estado en la web. Lo que no necesita salir, no sale: vive en el servidor. Lo que debe salir pero no cambiar, se firma. Lo que debe salir y permanecer oculto, se cifra o —casi siempre mejor— se reemplaza por una llave opaca que apunta a un dato guardado a salvo. La sesión, que estudiamos como un patrón elegante, no es más que la conclusión lógica de este razonamiento: cuando el territorio es hostil, no mandes el tesoro, manda el número de la caja fuerte.
- Fija una cookie de preferencias con
Astro.cookies.setpasándole un objeto y recupérala conget(...).json(). - Añádele
httpOnly,secureysameSite: 'lax'y observa en las herramientas del navegador cómo cambian sus atributos. - Guarda un
rolfirmado con el helper HMAC y verifica que, al editar la cookie a mano,verificardevuelvenull. - Razona por qué firmar no oculta el valor y en qué caso preferirías una sesión con id opaco en lugar de una cookie firmada.