Headers HTTP, cookies y sesiones con curl y PHP

Curso de HTTP 3: mojibake byte por byte, Set-Cookie en el cookie jar de curl, una cookie Secure descartada sin aviso, sesiones PHP y session fixation.

Serie: curso de HTTP 3/6 (intermedio). Para quien ya sabe leer curl -v (clase 1) y ahora depura aplicaciones web reales: texto que se convierte en basura, logins que no se mantienen, cookies que "se enviaron" pero nunca vuelven. Vas a reproducir cada uno de esos casos con una app PHP de 60 líneas y curl, y vas a saber qué header o qué atributo fue el responsable.

Si alguna vez armaste el login del proyecto final de un bootcamp y funcionaba en tu computadora pero no en el servidor de pruebas, o te tocó migrar un sistema viejo de un cliente y los nombres con tildes y eñes empezaron a salir como �, esta clase es para ti. HTTP en sí no recuerda nada entre una petición y otra; todo lo que se siente como estado, desde el idioma elegido hasta una sesión iniciada, viaja en headers. Cuando algo de ese estado se rompe, la causa casi siempre es una sola línea de header, y solo la ves si miras el intercambio crudo. Todos los ejemplos se ejecutaron con PHP 8.5.6 (php -S) y curl 8.7.1 en una Mac con Apple Silicon (M1 Pro); las rutas se acortaron a ~/demo/, y las únicas peticiones externas fueron unas pocas llamadas curl -sI a example.com y a nuestro propio sitio.

La app de demostración

Un archivo, un switch, y cada caso muestra un comportamiento. Guárdalo como ~/demo/app.php:

<?php
// php -d session.save_path=./sess -S 127.0.0.1:18104 app.php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

switch ($path) {
case '/text-sjis':      // bytes are Shift_JIS, header says UTF-8
    header('Content-Type: text/plain; charset=UTF-8');
    echo mb_convert_encoding("こんにちは\n", 'SJIS', 'UTF-8');
    break;
case '/text-euckr':     // bytes are EUC-KR (CP949), header says UTF-8
    header('Content-Type: text/plain; charset=UTF-8');
    echo mb_convert_encoding("안녕하세요\n", 'CP949', 'UTF-8');
    break;
case '/text-ok':
    header('Content-Type: text/plain; charset=UTF-8');
    echo "こんにちは 안녕하세요\n";
    break;
case '/json-default':
    echo json_encode(['msg' => 'こんにちは']), "\n";   // no Content-Type set
    break;
case '/json':
    header('Content-Type: application/json');
    echo json_encode(['msg' => 'こんにちは'], JSON_UNESCAPED_UNICODE), "\n";
    break;
case '/prefs':
    setcookie('theme', 'dark', ['expires' => time() + 3600, 'path' => '/', 'samesite' => 'Lax']);
    setcookie('beta', '1', ['path' => '/app']);                 // only for /app/...
    setcookie('secure_only', 'x', ['path' => '/', 'secure' => true]);
    echo "prefs set\n";
    break;
case '/login':
    session_start();
    session_regenerate_id(true);           // new id after login
    $_SESSION['user'] = $_POST['user'] ?? 'guest';
    echo "hello {$_SESSION['user']}\n";
    break;
case '/me':
    session_start();
    $_SESSION['n'] = ($_SESSION['n'] ?? 0) + 1;
    echo 'user=' . ($_SESSION['user'] ?? '(none)') . " visits={$_SESSION['n']}\n";
    break;
case '/logout':
    session_start();
    $_SESSION = [];
    setcookie(session_name(), '', ['expires' => 1, 'path' => '/']);
    session_destroy();
    echo "bye\n";
    break;
case '/app/x':
    echo 'cookies seen: ' . json_encode($_COOKIE) . "\n";
    break;
case '/late':
    echo "oops, output first\n";
    setcookie('late', '1');                // too late: headers already sent
    break;
case '/plain':
    echo "ok\n";
    break;
default:
    http_response_code(404);
}
cd ~/demo && mkdir -p sess && php -d session.save_path=./sess -S 127.0.0.1:18104 app.php

session.save_path=./sess guarda los archivos de sesión en una carpeta que después podemos contar.

Headers: nombres, mayúsculas y lo que envía producción

Un header es Nombre: valor. Los nombres no distinguen mayúsculas de minúsculas: php -S envía Content-type, la llamada header() de PHP envió Content-Type, y en HTTP/2 todos los nombres van en minúsculas. Esta es una petición HEAD real a example.com (quitamos una línea de request-id de Cloudflare):

$ curl -sI https://example.com/
HTTP/2 200 
date: Thu, 12 Nov 2026 14:29:10 GMT
content-type: text/html; charset=utf-8
server: cloudflare
last-modified: Thu, 05 Nov 2026 21:17:41 GMT
allow: GET, HEAD
accept-ranges: bytes
age: 8742
cf-cache-status: HIT
alt-svc: h3=":443"; ma=86400

Y parte de la respuesta de este sitio, que muestra los headers de seguridad que debería enviar un sitio estático:

content-type: text/html; charset=UTF-8
vary: Accept-Encoding
etag: W/"22d0-6af53ceb-1d219362a7cb89c4;gz"
content-security-policy: upgrade-insecure-requests
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
cache-control: public, max-age=600

O sea, el código que busca headers['Content-Type'] con las mayúsculas exactas funciona contra un servidor y falla contra otro. Toda librería HTTP seria ofrece búsqueda sin distinguir mayúsculas; úsala. age, etag y cache-control son tema de la clase 4.

Content-Type, charset y mojibake

Content-Type le dice al cliente qué son los bytes. Para texto, debería incluir charset. El header es solo una etiqueta: no convierte nada.

El caso correcto, con los bytes que muestra xxd:

$ curl -s http://127.0.0.1:18104/text-ok
こんにちは 안녕하세요
$ curl -s http://127.0.0.1:18104/text-ok | xxd | head -2
00000000: e381 93e3 8293 e381 abe3 81a1 e381 af20  ............... 
00000010: ec95 88eb 8595 ed95 98ec 84b8 ec9a 940a  ................

Cada carácter japonés o coreano ocupa 3 bytes en UTF-8 (e3 81 93 es こ). Ahora la etiqueta miente: el header dice UTF-8 pero los bytes están en Shift_JIS, como saldrían de una base de datos vieja o de un CSV exportado. (Con texto en español pasa lo mismo cuando los bytes están en Latin-1 o Windows-1252 y el header dice UTF-8: la "ñ" y las tildes son las primeras en romperse.)

$ curl -s http://127.0.0.1:18104/text-sjis
����ɂ���
$ curl -s http://127.0.0.1:18104/text-sjis | xxd
00000000: 82b1 82f1 82c9 82bf 82cd 0a              ...........
$ curl -s http://127.0.0.1:18104/text-sjis | iconv -f SHIFT_JIS -t UTF-8
こんにちは

Lo mismo con coreano en EUC-KR (CP949):

$ curl -s http://127.0.0.1:18104/text-euckr
�ȳ��ϼ���
$ curl -s http://127.0.0.1:18104/text-euckr | xxd
00000000: bec8 b3e7 c7cf bcbc bfe4 0a              ...........

El error contrario (bytes UTF-8 leídos con una codificación vieja) produce el conocido "縺薙s縺ォ縺。縺ッ" para こんにちは cuando se decodifica como CP932 (lo comprobamos con bytes.decode de Python). Los caracteres � son el carácter de reemplazo U+FFFD: el decodificador se rindió ante bytes que no son UTF-8 válido. Diagnostica el mojibake mirando los bytes, no la pantalla. xxd te dice en qué codificación están realmente los datos; el header te dice lo que el servidor afirma.

JSON tiene sus propias rarezas:

$ curl -si http://127.0.0.1:18104/json-default | grep -iE 'content-type|msg'
Content-type: text/html; charset=UTF-8
{"msg":"こんにちは"}
$ curl -si http://127.0.0.1:18104/json | grep -iE 'content-type|msg'
Content-Type: application/json
{"msg":"こんにちは"}

Sin una llamada a header(), PHP etiqueta el JSON como text/html, y json_encode escapa lo que no es ASCII como \uXXXX (JSON válido, pero con el doble de bytes e ilegible en los logs) hasta que le pasas JSON_UNESCAPED_UNICODE. JSON se define como UTF-8 (RFC 8259), así que application/json no necesita parámetro charset.

Una cookie es un pequeño par nombre=valor que el servidor le pide al cliente que guarde (header de respuesta Set-Cookie) y que el cliente devuelve en las peticiones siguientes (header de petición Cookie). -c ARCHIVO le dice a curl que guarde las cookies, y -b ARCHIVO que las envíe.

$ curl -si -c jar.txt http://127.0.0.1:18104/prefs
HTTP/1.1 200 OK
Host: 127.0.0.1:18104
Date: Thu, 12 Nov 2026 14:30:58 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Set-Cookie: theme=dark; expires=Thu, 12 Nov 2026 15:30:58 GMT; Max-Age=3600; path=/; SameSite=Lax
Set-Cookie: beta=1; path=/app
Set-Cookie: secure_only=x; path=/; secure
Content-type: text/html; charset=UTF-8

prefs set

El jar es un archivo de texto plano (separado por tabulaciones: dominio, incluir subdominios, path, secure, vencimiento como tiempo Unix, nombre, valor):

127.0.0.1   FALSE   /   TRUE    0   secure_only x
127.0.0.1   FALSE   /app    FALSE   0   beta    1
127.0.0.1   FALSE   /   FALSE   1794497458  theme   dark

Un vencimiento 0 significa una cookie de sesión: sin Expires/Max-Age, así que vive hasta que se cierra el navegador. Path decide a dónde se envía una cookie:

$ curl -sv -b jar.txt http://127.0.0.1:18104/app/x 2>&1 | grep -E '^> Cookie|cookies seen'
> Cookie: beta=1; secure_only=x; theme=dark
cookies seen: {"beta":"1","secure_only":"x","theme":"dark"}
$ curl -sv -b jar.txt http://127.0.0.1:18104/plain 2>&1 | grep -E '^> Cookie'
> Cookie: secure_only=x; theme=dark

beta solo fue a /app/.... Ojo: Path no es un límite de seguridad; cualquier página del mismo host puede leer o sobrescribir cookies por otros medios.

La trampa de Secure. En http://127.0.0.1, curl guardó y envió secure_only, aunque la conexión no estaba cifrada. Es a propósito: curl, igual que los navegadores actuales, trata a localhost como un contexto seguro. Dale al mismo servidor un nombre de host normal con --resolve (sin DNS de por medio) y el resultado cambia:

$ curl -sv --resolve app.test:18104:127.0.0.1 -c jar2.txt http://app.test:18104/prefs 2>&1 | grep -iE 'cookie|< Set'
* Added cookie theme="dark" for domain app.test, path /, expire 1794497464
< Set-Cookie: theme=dark; expires=Thu, 12 Nov 2026 15:31:04 GMT; Max-Age=3600; path=/; SameSite=Lax
* Added cookie beta="1" for domain app.test, path /app, expire 0
< Set-Cookie: beta=1; path=/app
< Set-Cookie: secure_only=x; path=/; secure
$ grep -v '^#' jar2.txt
app.test    FALSE   /app    FALSE   0   beta    1
app.test    FALSE   /   FALSE   1794497464  theme   dark

Ninguna línea "Added cookie" y ninguna advertencia para secure_only: se descartó en silencio. Un login que "funciona en localhost pero no en el servidor de staging por http" muchas veces termina aquí.

Atributo Qué hace Cuándo usarlo
Max-Age / Expires duración; sin él, es una cookie de sesión preferencias; omítelo en sesiones de login que deben terminar con el navegador
Path solo se envía bajo esa ruta rara vez hace falta; lo normal es /
Domain también se envía a los subdominios solo si api. y www. deben compartirla; omitirlo es más estricto
Secure solo por HTTPS siempre en producción
HttpOnly JavaScript no puede leerla siempre para ids de sesión
SameSite=Lax no se envía en la mayoría de las peticiones cross-site (un POST desde otro sitio) la opción por defecto; Strict para áreas de administración, None; Secure solo para uso cross-site real

Una sesión guarda los datos en el servidor y al cliente le da solo un id. En PHP, session_start() lee la cookie PHPSESSID, carga el archivo sess_<id> y crea ambos si no existen.

Diagrama de flujo. Petición 1: curl envía por POST user=mina a /login. PHP llama a session_regenerate_id, escribe los datos en un archivo llamado sess_ seguido del id, y responde con Set-Cookie PHPSESSID igual al id, path barra. curl la guarda en el archivo cookie jar. Petición 2: curl envía Cookie PHPSESSID con el mismo id a /me, PHP lee el archivo sess_ correspondiente e imprime user=mina. Un camino punteado muestra que, sin el cookie jar, cada petición recibe una sesión nueva y vacía.
Cómo sobrevive una sesión PHP entre peticiones. HTTP no recuerda nada; la cookie lleva un id, y el servidor guarda los datos en un archivo con ese nombre.

Sin cookie jar, curl tira la cookie, así que cada petición es un desconocido:

$ curl -s http://127.0.0.1:18104/me
user=(none) visits=1
$ curl -s http://127.0.0.1:18104/me
user=(none) visits=1
$ curl -si http://127.0.0.1:18104/me | grep -iE 'set-cookie|cache|expires|pragma'
Set-Cookie: PHPSESSID=8e7bb7345df4509991e181379494cf25; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

Mira lo que session_start() agregó por su cuenta: headers que prohíben la caché. Una página que inicia una sesión es personal, así que PHP impide que las cachés la guarden. Si un CDN que está adelante ignora esos headers, la página de un usuario puede servírsele a otro; mira un CDN cachea respuestas de API y redirecciones (en inglés).

Con un jar la sesión sobrevive, y un login conserva los datos pero cambia el id:

$ curl -s -c sjar.txt -b sjar.txt http://127.0.0.1:18104/me
user=(none) visits=1
$ curl -s -c sjar.txt -b sjar.txt http://127.0.0.1:18104/me
user=(none) visits=2
$ grep PHPSESSID sjar.txt
127.0.0.1   FALSE   /   FALSE   0   PHPSESSID   b23d467a32dcb4c9014ff5a8700c4ee1
$ curl -si -c sjar.txt -b sjar.txt -d user=mina http://127.0.0.1:18104/login | grep -iE 'set-cookie|hello'
Set-Cookie: PHPSESSID=345174e3fecbca37cfe447cf3b5e3fba; path=/
hello mina
$ curl -s -c sjar.txt -b sjar.txt http://127.0.0.1:18104/me
user=mina visits=3
$ curl -si -c sjar.txt -b sjar.txt http://127.0.0.1:18104/logout | grep -iE 'set-cookie|bye'
Set-Cookie: PHPSESSID=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/
bye
$ curl -s -c sjar.txt -b sjar.txt http://127.0.0.1:18104/me
user=(none) visits=1

visits=3 después del login muestra que los datos pasaron al id nuevo. Para borrar una cookie, el servidor la vuelve a enviar con una fecha en el pasado; PHP escribió deleted y Max-Age=0, y curl la sacó del jar.

Errores comunes

1. "Cannot modify header information - headers already sent". /late imprime texto y después llama a setcookie(). En nuestro primer servidor la cookie llegó igual:

$ curl -si http://127.0.0.1:18104/late | grep -E 'Set-Cookie|oops'
Set-Cookie: late=1
oops, output first

porque el php.ini de Homebrew pone output_buffering = 4096: PHP retiene los primeros 4 KB de salida, así que todavía se podía agregar el header. Arranca el servidor con -d output_buffering=0 (como hacen muchas configuraciones de producción, y como hace siempre la CLI) y el mismo código falla:

oops, output first
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent by (output started at ~/demo/app.php:53) in <b>~/demo/app.php</b> on line <b>54</b><br />

Ningún Set-Cookie. También pasa con el buffering activado si la página primero imprime más de 4 KB, o con un BOM de UTF-8 o una línea en blanco antes de <?php en un archivo incluido. Solución: envía todos los headers y cookies antes de cualquier salida, y lee la ubicación de "output started at": apunta al culpable.

2. Aceptar cualquier id de sesión (session fixation). Los valores por defecto de PHP aquí eran session.use_strict_mode => Off, session.cookie_httponly => Off, session.cookie_samesite => no value (php -i). Con el modo estricto apagado, un cliente puede elegir su propio id de sesión:

$ curl -si -b 'PHPSESSID=attacker123' http://127.0.0.1:18104/me | grep -iE 'set-cookie|user='
user=(none) visits=1
$ ls sess | grep attacker
sess_attacker123

PHP creó sess_attacker123 y ni siquiera envió una cookie nueva. Si un atacante logra plantar ese id en el navegador de una víctima (un enlace armado en un sitio que acepta ids en la URL, o una cookie puesta desde un subdominio hermano) y la víctima inicia sesión sin que el id cambie, el atacante comparte la sesión iniciada. En un segundo servidor arrancado con -d session.use_strict_mode=1 -d session.cookie_httponly=1 -d session.cookie_samesite=Lax:

$ curl -si -b 'PHPSESSID=attacker456' http://127.0.0.1:18105/me | grep -iE 'set-cookie|user='
Set-Cookie: PHPSESSID=8574054420525f021198b29209f694f2; path=/; HttpOnly; SameSite=Lax
user=(none) visits=1

El id desconocido se rechazó, se emitió uno nuevo, y la cookie ahora lleva HttpOnly y SameSite. Solución: activa el modo estricto, llama a session_regenerate_id(true) al iniciar sesión (como hace /login) y configura cookie_httponly, cookie_samesite y, con HTTPS, cookie_secure. Los tokens CSRF, que van encima de todo esto, se tratan en seguridad en PHP: XSS, CSRF y sesiones (en inglés).

Medido: lo que cuestan las sesiones

Los archivos de sesión son baratos por petición, pero se acumulan cuando los clientes ignoran las cookies. 200 peticiones a /me desde curl, con la carpeta sess vacía cada vez:

Gráfico de barras. Sin cookie jar, 200 peticiones dejaron 200 archivos de sesión. Con curl -c y -b compartiendo un mismo cookie jar, las mismas 200 peticiones dejaron 1 archivo de sesión cuyo contador llegó a 200.
Archivos de sesión que quedan en el servidor después de 200 peticiones con curl a una página que llama a session_start() (PHP 8.5.6, php -S, Apple M1 Pro). Un cliente que ignora las cookies, como la mayoría de los bots, crea una sesión nueva en cada petición.

Sin jar: 200 archivos (cada uno guarda 6 bytes, n|i:1;, pero ocupa un bloque de 4 KB en este disco APFS, 800 KB en total según du). Con jar: 1 archivo que contiene n|i:200;. Los crawlers y los scripts de monitoreo se comportan como el primer caso, y por eso una página que no necesita sesión no debería llamar a session_start(). PHP borra los archivos viejos con su recolector de basura después de session.gc_maxlifetime (1440 segundos aquí), con cierta probabilidad en cada petición.

El costo en tiempo por petición fue chico. 50 peticiones de cada una después de un calentamiento, con jar, mediana: /plain 0.544 ms, /me con session_start() y escritura de archivo 0.579 ms (Apple M1 Pro, archivos en el SSD local). En un hosting compartido con disco lento, o con muchas peticiones en paralelo de un mismo usuario (PHP bloquea el archivo de sesión hasta que termina el script), el panorama puede ser peor; eso no lo medimos aquí.

Límites honestos y notas de versión

  • Las cookies están especificadas en la RFC 6265 (2011). SameSite está en su borrador de revisión (RFC 6265bis), que, hasta donde sabemos, sigue siendo un Internet-Draft; los navegadores lo implementan desde hace años, y Chrome hizo de Lax el valor por defecto para cookies sin el atributo en 2020. Nada de esto se ejecutó en un navegador.
  • curl 8.7.1 trató 127.0.0.1 como seguro para las cookies y descartó la cookie Secure para app.test por http. Los navegadores también tratan http://localhost como potencialmente confiable, pero los detalles cambian entre navegadores y versiones; no los probamos.
  • Los valores por defecto de PHP (use_strict_mode, cookie_httponly, cookie_samesite, output_buffering) son los de este PHP 8.5.6 de Homebrew y su php.ini de desarrollo. Los hostings compartidos suelen cambiarlos; revísalos con phpinfo() en el servidor real.
  • El id de sesión tenía aquí 32 caracteres hexadecimales. PHP 8.4 marcó como obsoletas las opciones session.sid_length y session.sid_bits_per_character; no probamos otros valores.
  • Cómo se ven las cadenas con mojibake depende de la fuente y la configuración de tu terminal; los bytes de xxd, no.

Ejercicios

1. Un compañero prueba una API de login con curl -X POST -d 'user=a&pass=b' https://staging.example.test/login y después curl https://staging.example.test/me, y recibe "not logged in". ¿Qué está mal, y cuáles son los dos comandos corregidos?

2. Una página de importación de CSV muestra � en todos los nombres coreanos o japoneses (o en cada "ñ" y cada tilde), pero la base de datos se ve bien en la herramienta de administración. ¿Qué dos comandos de esta clase te dicen si el problema está en los bytes o en la etiqueta?

Respuestas

1. curl no guardó la cookie de sesión, así que la segunda petición no tenía header Cookie y recibió una sesión nueva y vacía. Usa un jar en las dos: curl -c jar.txt -b jar.txt -d 'user=a&pass=b' https://staging.example.test/login y después curl -c jar.txt -b jar.txt https://staging.example.test/me. (Quita el -X POST; -d ya implica POST.)

2. curl -sI URL | grep -i content-type muestra la etiqueta (el charset que dice el servidor), y curl -s URL | xxd | head muestra los bytes. El japonés y el coreano en UTF-8 empiezan con e3…ed en grupos de 3 bytes; Shift_JIS se ve como 82 b1, EUC-KR como be c8. Si los bytes y la etiqueta no coinciden, convierte los datos (por ejemplo iconv -f CP949 -t UTF-8) o corrige el header.

Lo que sigue

Preguntas frecuentes

¿Por qué mi página muestra caracteres raros como ����ɂ��� si puse charset=UTF-8?

Porque el header y los bytes no coinciden. La página envió Content-Type: text/plain; charset=UTF-8, pero los bytes del cuerpo estaban en Shift_JIS (82 b1 82 f1 …). El header solo etiqueta los bytes; no los convierte. Convierte los datos a UTF-8 (iconv -f SHIFT_JIS mostró el texto original) o corrige la etiqueta para que coincida.

¿Por qué mi login no se mantiene cuando lo pruebo con curl?

curl no guarda cookies si no se lo pides. Sin un cookie jar, tres llamadas a una página con sesión imprimieron visits=1 cada una y crearon un archivo de sesión nuevo cada vez. Con -c jar.txt -b jar.txt el contador pasó de 1 a 2, y el login sobrevivió hasta la siguiente petición.

¿Qué pasa con una cookie con el atributo Secure en un sitio http:// sin HTTPS?

Se descarta. Sobre http://app.test (apuntado a 127.0.0.1 con --resolve), curl 8.7.1 guardó theme y beta pero descartó secure_only sin avisar. En http://127.0.0.1 curl sí la guardó, porque trata a localhost como contexto seguro, así que probar en localhost puede esconder el problema.

  • #http
  • #intermedio
  • #headers
  • #cookies
  • #sesiones
  • #charset
  • #php
  • #curl

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑