Saltar a contenido

LA COOKIE DE SESIÓN AL DETALLE

Estos apuntes amplían la explicación de cómo funcionan las cookies de sesión en Flask. Primero veremos qué es una cookie y, en concreto, la cookie de sesión: qué la genera, qué contiene, qué garantías ofrece. Después aplicaremos todo eso al caso particular de los mensajes flash, donde el mecanismo aparece de la forma más visible: un mensaje sale del servidor, vive un tiempo en el navegador y vuelve.

Qué es exactamente

Una cookie es un pequeño dato que el servidor guarda en el navegador del cliente y que el navegador le devuelve, de forma automática, en cada petición posterior al mismo dominio. No es código ni un fichero del servidor: es solo un par nombre = valor que viaja con cada petición. El servidor la entrega usando una cabecera HTTP y el navegador la guarda hasta que caduca o se borra.

La cookie de sesión es la que Flask usa para mantener el contenido del diccionario session entre peticiones. Cada vez que una vista modifica session (guardando un identificador de usuario, una preferencia, un mensaje flash o cualquier otro dato), Flask serializa todo el diccionario a JSON, lo firma con app.secret_key y lo guarda en una cookie llamada session. En la siguiente petición, el navegador devuelve esa cookie, Flask la verifica, la decodifica y reconstruye el diccionario session como si nunca se hubiera ido.

Flask no tiene "memoria" entre peticiones por sí mismo. La sesión vive en el navegador del cliente, no en el servidor. Cada petición trae consigo, en forma de cookie, el estado completo que el servidor le había confiado en la respuesta anterior.

En las secciones siguientes desgranamos qué hay dentro del valor de esa cookie y qué garantías ofrece la firma. Más adelante, en la sección 4, veremos las cabeceras HTTP en bruto que materializan este intercambio.

Estructura del valor

El valor de la cookie tiene tres segmentos separados por puntos:

eyJfZmxhc2hlcyI6...  .  Z8a9Tg  .  abcDEF123...
└──── payload ────┘   └─tiempo─┘   └── firma ──┘
Segmento Qué contiene Cómo se codifica
payload El diccionario session (incluido _flashes) JSON → base64 url-safe
timestamp Cuándo se firmó la cookie Entero en base64
firma HMAC sobre payload + timestamp (firma criptográfica con clave secreta — quien no conoce la clave no puede falsificarla) Calculado con app.secret_key

Qué es un payload

Payload (carga útil) es un término genérico en informática: la parte de un mensaje que transporta los datos, frente a las cabeceras o metadatos que sirven para entregarlo o validarlo.

Contexto Payload Envoltorio
Paquete TCP/IP Datos de aplicación Cabeceras IP y TCP
Petición HTTP POST Cuerpo (request.form, request.json) Cabeceras HTTP
Cookie de sesión Flask session serializado a JSON Timestamp + firma HMAC

La idea común: separar lo que se quiere entregar de lo que sirve para entregarlo o validarlo.

Firmar no es cifrar

La firma garantiza que el navegador no ha modificado la cookie. No la oculta. El payload viaja en claro: cualquier persona con acceso al navegador puede decodificarlo en una sola línea de Python y leer su contenido tal cual lo escribió la aplicación. En la sección 4 (Cómo verlo en las herramientas del navegador) lo hacemos paso a paso copiando el valor real desde DevTools; aquí basta con quedarse con la idea: si lo metiste en session, asume que el usuario y cualquiera con acceso a su navegador pueden leerlo.

Mala práctica: guardar datos sensibles en session

## Incorrecto: el contenido es legible para cualquiera con la cookie
session['tarjeta'] = '4111-1111-1111-1111'
session['password_hash'] = '$2b$12$...'
flash(f"Bienvenido, tu DNI es {usuario.dni}", 'info')
## Correcto
session['usuario_id'] = 42  ## identificador opaco; los datos reales quedan en BBDD
flash('Bienvenido.', 'info')

El intercambio paso a paso

Hasta aquí hemos hablado de la cookie de sesión en general. A partir de ahora la observamos en un caso concreto: una operación de compra que produce un cambio y avisa al usuario con un mensaje flash. La ruta es la que ya conoces de actividades anteriores:

@app.route('/comprar', methods=['GET', 'POST'])
def comprar():
    if request.method == 'POST':
        try:
            cambio = servicio.comprar()
            importe = f"{cambio:.2f}".replace(".", ",")
            flash(f"Compra realizada. Cambio: {importe} EUR.", 'exito')
            return redirect(url_for('inicio'))
        except ValueError as e:
            return render_template('comprar.html', saldo=servicio.saldo(), error=str(e)), 400
    return render_template('comprar.html', saldo=servicio.saldo(), error=None)

Entre el flash(...) del route y el get_flashed_messages(...) de la plantilla base hay un viaje completo de ida y vuelta. Una sola operación de compra desencadena tres peticiones HTTP consecutivas. Veamos qué pasa con la cookie en cada una.

Diagrama de secuencia

sequenceDiagram
    participant N as Navegador
    participant F as Flask

    N->>F: POST /comprar<br/>Cookie: session=v1
    Note over F: flash("Compra realizada. Cambio: 0,50 EUR.", 'exito')<br/>session["_flashes"] = [('exito', 'Compra realizada...')]
    F-->>N: 302 Found<br/>Location: /inicio<br/>Set-Cookie: session=v2 (con _flashes)

    N->>F: GET /inicio<br/>Cookie: session=v2
    Note over F: get_flashed_messages() devuelve<br/>[('exito', 'Compra realizada...')]<br/>y vacía session["_flashes"]
    F-->>N: 200 OK<br/>HTML con el mensaje<br/>Set-Cookie: session=v3 (sin _flashes)

    N->>F: GET /inicio (recarga F5)<br/>Cookie: session=v3
    Note over F: get_flashed_messages() devuelve []
    F-->>N: 200 OK<br/>HTML sin mensaje

Tres peticiones, tres versiones de la cookie. El mensaje vive exactamente entre v2 y v3.

Qué cambia en cada salto

En la primera visita absoluta v1 aún no existe y el navegador envía el POST sin cookie; Flask la crea en la respuesta. En el diagrama asumimos que ha habido navegación previa, así que el navegador ya tiene una v1 que mandar.

Versión Contenido del payload Generada en Devuelta en
v1 Sesión vacía o sin _flashes Petición previa (puede ser la primera del navegador) POST /comprar
v2 {'_flashes': [('exito', '...')]} Respuesta del POST GET /inicio
v3 Sin _flashes (vaciada) Respuesta del GET GET /inicio tras F5

El punto clave: Flask reescribe la cookie cada vez que session cambia. La primera vez añade _flashes; la segunda los retira, porque get_flashed_messages() los consumió al renderizar la plantilla.

Mala práctica: omitir el redirect después de flash

## Incorrecto: rompe el patrón Post/Redirect/Get
@app.route('/comprar', methods=['POST'])
def comprar():
    cambio = servicio.comprar()
    importe = f"{cambio:.2f}".replace(".", ",")
    flash(f"Compra realizada. Cambio: {importe} EUR.", 'exito')
    return render_template('inicio.html')  ## sin redirect
## Correcto
@app.route('/comprar', methods=['POST'])
def comprar():
    cambio = servicio.comprar()
    importe = f"{cambio:.2f}".replace(".", ",")
    flash(f"Compra realizada. Cambio: {importe} EUR.", 'exito')
    return redirect(url_for('inicio'))
Sin redirect, si el usuario pulsa F5 sobre la página resultante el navegador reenvía el POST y la compra se ejecuta otra vez. Flash sin PRG es flash que no resuelve el problema que vino a resolver.

Mala práctica: invocar get_flashed_messages dos veces en la misma plantilla

{# Incorrecto: la segunda llamada devuelve [] porque la primera ya consumió la lista #}
{% if get_flashed_messages() %}
    <p>Tienes notificaciones nuevas:</p>
{% endif %}
<ul>
    {% for msg in get_flashed_messages() %}
        <li>{{ msg }}</li>
    {% endfor %}
</ul>
{# Correcto: una sola llamada, resultado guardado con {% with %} #}
{% with mensajes = get_flashed_messages() %}
    {% if mensajes %}
        <p>Tienes notificaciones nuevas:</p>
        <ul>
            {% for msg in mensajes %}
                <li>{{ msg }}</li>
            {% endfor %}
        </ul>
    {% endif %}
{% endwith %}
get_flashed_messages() no solo devuelve los mensajes: también los consume vaciando _flashes. La segunda llamada en la misma petición se encuentra la lista ya vacía. Por eso lo correcto es invocarla una sola vez y guardar el resultado con {% with %}.

Cómo verlo en las herramientas del navegador

Las DevTools (F12 en Chrome o Firefox) permiten observar todo el intercambio. Dos pestañas son relevantes: Application (en Firefox: Almacenamiento) para ver la cookie almacenada, y Network (Red) para ver las cabeceras de cada petición.

Ruta: Application → Storage → Cookies → http://localhost:5000.

Aparece una entrada con estos campos:

Campo Valor de ejemplo
Name session
Value eyJfZmxhc2hlcyI6...Z8a9Tg.abcDEF123
Domain localhost
Path /
Expires Session
HttpOnly
Secure
SameSite Lax

El Value que ves aquí es exactamente la versión más reciente que el navegador ha recibido: en nuestro escenario, v3 después de pulsar F5, o v2 justo después de la redirección si todavía no has recargado.

HttpOnly significa que el JavaScript de la página no puede leer la cookie con document.cookie — mitigación contra ataques XSS. SameSite=Lax limita el envío en peticiones cross-site, mitigación parcial contra CSRF. Expires: Session indica que la cookie se borra al cerrar el navegador. Domain y Path determinan a qué peticiones se adjunta la cookie: aquí, cualquier ruta de localhost. Secure está vacío porque servimos por HTTP plano; en producción con HTTPS aparecería marcado y el navegador no enviaría la cookie por conexiones inseguras.

Cómo se ve a nivel de protocolo

Antes de mirar las DevTools, conviene haber visto al menos una vez las cabeceras HTTP en bruto. Cuando el servidor responde a la petición que ejecutó flash('Compra realizada. Cambio: 0,50 EUR.', 'exito'):

HTTP/1.1 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 219
Location: /inicio
Set-Cookie: session=eyJfZmxhc2hlcyI6W3siIHQiOlsiZXhpdG8iLCJDb21wcmEgcmVhbGl6YWRhLiBDYW1iaW86IDAsNTAgRVVSLiJdfV19.Z8a9Tg.abcDEF123xyz; HttpOnly; Path=/

El navegador guarda esa cookie y la reenvía en la siguiente petición — la GET /inicio lanzada automáticamente por el 302:

GET /inicio HTTP/1.1
Host: localhost:5000
Cookie: session=eyJfZmxhc2hlcyI6W3siIHQiOlsiZXhpdG8iLCJDb21wcmEgcmVhbGl6YWRhLiBDYW1iaW86IDAsNTAgRVVSLiJdfV19.Z8a9Tg.abcDEF123xyz
Accept: text/html

Fíjate en la asimetría de los nombres: el servidor usa Set-Cookie para entregarla y el navegador usa Cookie (sin Set-) para devolverla. Es el mismo valor viajando en sentidos opuestos.

Pestaña Network — las tres peticiones

Filtra por documento (Doc) y envía el formulario de compra. Aparecen las tres peticiones en orden cronológico.

1. POST /comprar

Cabeceras enviadas por el navegador:

POST /comprar HTTP/1.1
Host: localhost:5000
Cookie: session=v1
Content-Type: application/x-www-form-urlencoded

Cabeceras devueltas por Flask:

HTTP/1.1 302 Found
Location: /inicio
Set-Cookie: session=v2; HttpOnly; Path=/; SameSite=Lax

El valor v2 ya contiene _flashes dentro del payload. Flask siempre re-firma y re-envía la cookie cuando session cambia.

2. GET /inicio (lanzada automáticamente por el 302)

GET /inicio HTTP/1.1
Cookie: session=v2
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Set-Cookie: session=v3; HttpOnly; Path=/; SameSite=Lax

El HTML devuelto contiene el <li class="flash flash-exito">Compra realizada. Cambio: 0,50 EUR.</li>. La nueva cookie v3 ya no incluye _flashes: get_flashed_messages(with_categories=true) los consumió al renderizar la plantilla base.

3. Recarga manual (F5)

GET /inicio HTTP/1.1
Cookie: session=v3

Como _flashes está vacía, el HTML resultante no contiene ningún <li class="flash">. El mensaje ha desaparecido tras una sola visualización: ese es exactamente el comportamiento "flash" que el nombre describe.

Ejemplo: decodificar el payload manualmente

Para verificar que el texto del flash(...) viaja realmente dentro de la cookie, copia el valor completo de la cookie session desde la pestaña Application y decodifícalo.

Antes del código, un detalle de Flask: cuando el payload supera unos 600 bytes, Flask lo comprime con zlib antes de codificarlo en base64 y antepone un punto al valor de la cookie como marca. Por eso el siguiente bloque comprueba primero si el valor empieza por . y, si es así, descomprime tras decodificar.

import base64, json, zlib

## Valor completo copiado del navegador (incluido el punto inicial si lo hay)
valor = 'eyJfZmxhc2hlcyI6W3siIHQiOlsiZXhpdG8iLCJDb21wcmEgcmVhbGl6YWRhLiBDYW1iaW86IDAsNTAgRVVSLiJdfV19.Z8a9Tg.abcDEF'

## El payload es el primer segmento (o el segundo si el valor empieza por ".")
comprimido = valor.startswith('.')              ## punto inicial = payload comprimido
payload_b64 = valor.lstrip('.').split('.')[0]   ## quita el punto si lo hay y toma el primer segmento
datos = base64.urlsafe_b64decode(payload_b64 + '==')
if comprimido:
    datos = zlib.decompress(datos)
print(json.loads(datos))
{'_flashes': [{' t': ['exito', 'Compra realizada. Cambio: 0,50 EUR.']}]}

El mensaje exacto que pasaste a flash(...) aparece en claro. La firma del tercer segmento impide modificar este contenido sin que Flask lo detecte, pero no impide leerlo. La cadena ' t' (espacio + t) es una marca interna que Flask usa al serializar: como JSON no tiene tuplas (solo arrays), Flask las envuelve con esa marca para reconstruirlas al deserializar.

Qué tener claro al usar cookies en Flask

Los puntos que conviene fijar antes de seguir avanzando:

La sesión vive en el cliente, no en el servidor. Flask no guarda nada por su cuenta entre peticiones. Todo el contenido de session viaja en cada petición dentro de la cookie. El servidor es, en este sentido, stateless: depende de que el navegador le devuelva el estado en cada vuelta.

Firmada no es cifrada. El payload está codificado en base64 y, opcionalmente, comprimido con zlib. La firma HMAC impide modificarlo sin detección, pero cualquier persona con acceso al navegador puede leer el contenido. Por eso session y flash solo deben llevar identificadores opacos o mensajes ya pensados para mostrar al usuario, nunca contraseñas, tokens, datos personales o información de negocio sensible.

La secret_key es crítica. Si se filtra, un atacante puede falsificar cookies de sesión válidas. Si se cambia, todas las sesiones existentes se invalidan. El literal en código solo es aceptable en desarrollo local; en producción la clave se carga desde una variable de entorno.

Cualquier cambio en session reescribe la cookie. No hace falta acción explícita: Flask detecta la modificación al construir la respuesta y añade automáticamente un Set-Cookie. Eso es lo que permite que flash y get_flashed_messages funcionen sin sintaxis adicional, pero también lo que hace que cada petición transporte el estado completo — conviene mantenerlo pequeño.

Flash existe por culpa del redirect. Sin el patrón PRG no haría falta: bastaría con pasar el mensaje como variable al render_template. La cookie es el canal que cruza el hueco entre la petición que produce el dato y la que lo muestra. Llamar a flash sin un redirect posterior es una contradicción funcional.

get_flashed_messages consume. La función devuelve los mensajes y los borra. Una segunda invocación en la misma petición devuelve lista vacía.

Para depurar, ve a las DevTools. Si el mensaje no aparece, aparece dos veces o persiste tras la recarga, mira primero la pestaña Network: Cookie: y Set-Cookie: te dicen enseguida si el problema está en el servidor, en el navegador o en la plantilla.