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.
Cookies: Set-Cookie y el cookie jar de curl
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 |
Sesiones: estado construido con una sola cookie
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.
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:
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).
SameSiteestá 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 deLaxel 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.1como seguro para las cookies y descartó la cookieSecureparaapp.testpor http. Los navegadores también tratanhttp://localhostcomo 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 suphp.inide desarrollo. Los hostings compartidos suelen cambiarlos; revísalos conphpinfo()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_lengthysession.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
- Anterior: curso de HTTP 2/6: códigos de estado y redirecciones
- Siguiente: curso de HTTP 4/6: caché con ETag y Cache-Control
- Más adelante: HTTPS, TLS y certificados (en inglés), keep-alive y compresión (en inglés)
- Desde el principio: curso de HTTP 1/6: peticiones, respuestas y curl -v
- Sesiones y CSRF en código PHP: seguridad en PHP: XSS, CSRF y sesiones (en inglés)
- Todas las series de clases: la hoja de ruta para aprender a programar
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.