Todo funciona hasta que deja de hacerlo. El editor no guarda. El personalizador gira indefinidamente. Tu botón “Cargar más entradas” deja de cargar cualquier cosa. DevTools muestra una petición ordenada a /wp-admin/admin-ajax.php que devuelve 400 o 403, y el negocio pregunta por qué “el sitio web se niega a hacer clic”.
admin-ajax.php es uno de los viejos caballos de batalla de WordPress. También es un imán para controles de seguridad, errores de caché y ajustes de rendimiento “útiles”. La buena noticia: 400/403 suele ser un bloqueo deliberado. Tu trabajo es encontrar qué capa está diciendo no, y por qué.
Guía rápida de diagnóstico
Este es el camino “obtén evidencia en cinco minutos”. No es elegante. Es efectivo.
1) Confirma dónde se genera el 400/403: borde, WAF, servidor web o WordPress
- Mira las cabeceras de respuesta en DevTools en busca de pistas:
server:,cf-ray,x-sucuri-id,x-mod-security,x-cache,via. - Revisa el cuerpo: los productos WAF a menudo devuelven HTML o JSON de marca, o un genérico “Access denied.” WordPress típicamente devuelve
0o una pequeña cadena cuando un manejador AJAX muere temprano.
2) Reproduce desde el servidor y desde fuera
- Si la petición falla desde el propio servidor pero funciona desde tu portátil, sospecha cortafuegos del host, SELinux, proxy inverso local o enrutamiento de vhost.
- Si falla desde fuera pero funciona localmente, sospecha CDN/WAF o controles geográficos/IP.
3) Lee los registros que coincidan con la capa
- Registros CDN/WAF primero si están presentes. Si no puedes verlos, omite temporalmente el CDN con una anulación de hosts para golpear el origen y comparar.
- Registros de acceso+error de Nginx/Apache a continuación. Confirma que el código de estado viene del servidor web, no de un upstream.
- Registro de depuración PHP-FPM/WordPress al final. Un 403 rara vez proviene de PHP a menos que un plugin lo haga explícitamente.
4) Identifica el patrón específico de petición que se está bloqueando
Las llamadas a admin-ajax.php varían. Algunas son llamadas autenticadas de administración; otras son acciones públicas. Captura:
- Método HTTP (GET vs POST)
- Parámetros:
action, campos nonce, tamaño de la carga - Cookies (las sesiones autenticadas importan)
- Cabeceras Referer y Origin
5) Decide: allowlist, corregir lógica de la app o cambiar la arquitectura
La mayoría de las soluciones son una de estas:
- Allowlist las acciones AJAX conocidas y seguras en WAF/mod_security con un alcance restringido.
- Corregir nonces/autenticación si este es un problema a nivel de WordPress “no tienes permiso”.
- Dejar de cachear admin-ajax.php (o dejar de cachear páginas que incrustan nonces incorrectamente).
- Migrar al REST API para interacciones públicas y de alto volumen y reservar admin-ajax para flujos administrativos heredados.
Cómo funciona realmente admin-ajax.php (y por qué se bloquea)
admin-ajax.php es el endpoint RPC de la vieja escuela de WordPress. Lo llamas con action=some_hook_name, WordPress arranca, y luego llama a tu función manejadora si está registrada en wp_ajax_{action} (autenticada) o wp_ajax_nopriv_{action} (no autenticada).
Ese arranque es la clave. Por cada petición, WordPress carga mucho PHP. Los plugins pesados cargan más. Si lo llamas con demasiada frecuencia—polling, widgets de chat, scroll infinito—estás efectivamente creando una mini-API que no se comporta como una API moderna.
Y como vive bajo /wp-admin/, las herramientas de seguridad lo tratan como “admin”, incluso cuando alimenta interacciones del front-end. Muchos WAFs traen reglas de WordPress que apuntan específicamente a admin-ajax.php para fuerza bruta y abuso por bots. A veces tienen razón. A veces bloquean tu checkout.
Un modelo mental útil: admin-ajax es tanto un plano de control como un endpoint público de API, dependiendo de cómo lo usen los plugins. Los controles de seguridad prefieren que sea lo primero. Los plugins a menudo lo usan como lo segundo. El conflicto es inevitable.
Broma #1: admin-ajax.php es como la puerta de la oficina que es “solo para empleados”, excepto que cada cliente la encuentra y pide ayuda.
Qué significa 400 vs 403 en este contexto
400 Bad Request
400 suele significar “el servidor no aceptó lo que enviaste”, pero eso es vago. En la práctica, para admin-ajax 400 tiende a ser:
- Petición malformada: falta un parámetro requerido como
action, tipo de contenido inválido, codificación rota, cuerpo truncado. - Petición demasiado grande: límites de tamaño de cuerpo (
client_max_body_size), límites de inspección de cuerpo del WAF, límites de tamaño de cabeceras. - Módulo de seguridad rechaza la carga: mod_security devuelve 400 en algunas configuraciones, especialmente por violaciones en el cuerpo de la petición.
- Incompatibilidad de proxy upstream: HTTP/2 en el borde, HTTP/1.1 en el origen con reescritura extraña de cabeceras, o un balanceador normalizando algo mal.
403 Forbidden
403 es más honesto: “Te entendí. No tienes permiso.” Para admin-ajax, eso es comúnmente:
- Regla WAF/CDN que bloquea ruta, query, IP, país, ASN o score de amenaza.
- Regla del servidor web (
deny all, falta de allow para PHP, problemas de precedencia delocation). - Autenticación/cookies faltantes para acciones autenticadas.
- Cheques de nonce/capacidades de WordPress fallando y el manejador devuelve 403.
- Fail2ban o limitación de tasa que bloquea la IP del cliente.
Datos interesantes y contexto (sí, esto tiene historia)
- admin-ajax.php es anterior a la era del REST API de WordPress. Se convirtió en el “endpoint AJAX” por defecto mucho antes de que las APIs JSON modernas fueran comunes en el ecosistema WP.
- Históricamente, muchos plugins usaban admin-ajax para acciones del front-end porque estaba disponible universalmente y no requería enlaces permanentes bonitos.
- El REST API de WordPress llegó al núcleo en la 4.7, cambiando las mejores prácticas hacia endpoints
/wp-json/—sin embargo admin-ajax sigue en todas partes por compatibilidad retroactiva. - admin-ajax está bajo /wp-admin/, lo que hace que las herramientas de seguridad lo traten como “tráfico de administración”, incluso cuando no lo es.
- El sistema de nonces de WordPress es dependiente del tiempo y está ligado a las sesiones de usuario; cachear páginas que incrustan nonces puede romper llamadas AJAX de formas que parecen “403 aleatorios”.
- Algunos paquetes de reglas WAF apuntan explícitamente a nombres de parámetros comunes de WordPress como
action,_wpnoncey claves específicas de plugins porque los atacantes los reutilizan. - mod_security a menudo devuelve 403, pero también puede devolver 400 dependiendo de si trata el problema como “acceso denegado” o “cuerpo de petición inválido”.
- admin-ajax se abusa con frecuencia para DoS porque cada llamada puede desencadenar todo el bootstrap de WordPress y trabajo pesado en la base de datos.
- Muchas reglas de endurecimiento que “deshabilitan acceso a wp-admin” rompen admin-ajax porque bloquean todo
/wp-admin/sin excepciones.
Mapa de capas: todos los lugares donde puede morir una petición
Cuando ves 400/403, no discutas con el síntoma. Localiza al portero.
Layer 0: Navegador y JavaScript
- URL de endpoint equivocada: algunos sitios definen
ajaxurlincorrectamente (esquema mixto, dominio equivocado, ruta errónea tras la migración). - Error de preflight CORS si envías cabeceras personalizadas o peticiones cross-origin.
- Desajuste de tipo de contenido (
application/jsonenviado pero el servidor espera form encoding).
Layer 1: DNS y borde CDN
- El CDN cachea una respuesta que nunca debería cachearse.
- WAF bloquea la petición por ruta, user agent, patrón de query o limitación de tasa.
- Desafíos de protección contra bots rompen solicitudes XHR/fetch en segundo plano.
Layer 2: Balanceador / proxy inverso
- La normalización de cabeceras cambia la forma de la petición.
- Los límites de tamaño de cuerpo difieren entre borde y origen.
- Restricciones de método HTTP: algunos proxies bloquean POSTs a rutas “admin”.
Layer 3: Servidor web (Nginx/Apache)
- Precedencia de
locationen Nginx: un deny amplio capturaadmin-ajax.php. - Reglas .htaccess de Apache: snippets de hardening parecen correctos hasta que no lo son.
- Permisos/propietario de archivos raros: existe pero no es legible por el usuario web.
Layer 4: Módulos de seguridad (mod_security, OWASP CRS, Imunify, etc.)
- Reglas que disparan sobre el cuerpo o query string de la petición.
- El scoring por anomalía alcanza el umbral tras una actualización de plugin que cambia la forma de la carga.
- Falsos positivos en datos serializados, blobs base64 o JSON.
Layer 5: Núcleo de WordPress y plugins
- No hay manejador registrado para
action; WordPress devuelve0(no es 403, pero a menudo se interpreta como “bloqueado”). - Fallo en la verificación de nonce (
check_ajax_referer) y el manejador muere con 403. - Fallo en la comprobación de capacidades (
current_user_can) y el plugin niega acceso. - Plugins que intencionalmente bloquean tráfico de user agents “sospechosos” o referers faltantes.
Layer 6: Runtime PHP e infraestructura
- Timeouts que se manifiestan como comportamiento extraño del cliente, reintentos o cuerpos parciales que se tratan como 400.
- Disco lleno o agotamiento de inodos puede romper sesiones/caches, causando fallos de autenticación indirectos.
Tareas prácticas: comandos, salidas y qué significan
Necesitas pruebas, no intuiciones. Estas son tareas reales que puedes ejecutar en un host Linux típico con Nginx/Apache, PHP-FPM y WordPress. Cada tarea incluye: comando, salida de ejemplo, qué significa la salida y la decisión que tomas.
Task 1: Reproduce el fallo con curl (línea base)
cr0x@server:~$ curl -i -s -X POST https://example.com/wp-admin/admin-ajax.php -d 'action=heartbeat'
HTTP/2 403
date: Sat, 27 Dec 2025 10:12:11 GMT
content-type: text/html; charset=UTF-8
server: cloudflare
cf-ray: 88f0abc1234abcd-LHR
<html>...Access denied...</html>
Significado: La cabecera server: cloudflare y cf-ray indican “generado en el borde/WAF”. WordPress nunca vio esta petición.
Decisión: Deja de depurar WordPress. Ve a los registros y reglas del CDN/WAF. También intenta bypassear el borde para golpear el origen.
Task 2: Omitir CDN/borde y golpear el origen directamente (aislar)
cr0x@server:~$ curl -i -s --resolve example.com:443:203.0.113.10 https://example.com/wp-admin/admin-ajax.php -d 'action=heartbeat'
HTTP/2 200
date: Sat, 27 Dec 2025 10:12:44 GMT
content-type: text/html; charset=UTF-8
server: nginx
content-length: 1
0
Significado: El origen devuelve 200 con cuerpo 0. Ese es el “no output” por defecto de WordPress para una acción AJAX que no manejó, o espera auth/nonces.
Decisión: Si el borde falla pero el origen funciona, corrige la regla del WAF/límite de tasa/protección de bots para admin-ajax. Si la acción debería existir, verifica que esté registrada.
Task 3: Inspecciona los logs de acceso de Nginx para estado y comportamiento upstream
cr0x@server:~$ sudo tail -n 20 /var/log/nginx/access.log
198.51.100.24 - - [27/Dec/2025:10:12:44 +0000] "POST /wp-admin/admin-ajax.php HTTP/2.0" 200 1 "-" "curl/7.88.1"
198.51.100.24 - - [27/Dec/2025:10:13:02 +0000] "POST /wp-admin/admin-ajax.php HTTP/2.0" 403 153 "-" "Mozilla/5.0 ..."
Significado: El origen a veces devuelve 403 también. Eso significa que no es “solo Cloudflare”. Probablemente hay una regla del servidor web, mod_security o una denegación a nivel de WordPress para peticiones tipo navegador.
Decisión: Correlaciona con los registros de error y los registros del módulo de seguridad por marca temporal.
Task 4: Revisa el log de error de Nginx alrededor del evento
cr0x@server:~$ sudo grep -n "admin-ajax.php" /var/log/nginx/error.log | tail -n 5
41288#41288: *910 access forbidden by rule, client: 198.51.100.24, server: example.com, request: "POST /wp-admin/admin-ajax.php HTTP/2.0", host: "example.com"
Significado: “access forbidden by rule” es típico de una configuración Nginx deny/allow, no de PHP.
Decisión: Encuentra el bloque location correspondiente y corrige la precedencia para permitir admin-ajax.php (o permitir POSTs hacia él).
Task 5: Volcar la configuración efectiva de Nginx y localizar reglas deny
cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -nE "location|deny all|wp-admin|admin-ajax\.php" | head -n 40
1123: location ^~ /wp-admin/ { deny all; }
1158: location = /wp-admin/admin-ajax.php { include fastcgi_params; fastcgi_pass unix:/run/php/php8.2-fpm.sock; }
Significado: Tienes un deny amplio para /wp-admin/ y una excepción específica para admin-ajax. Eso puede funcionar—pero solo si el bloque de coincidencia exacta realmente se alcanza.
Decisión: Verifica el orden y los modificadores de las ubicaciones. location = debería ganar sobre coincidencias de prefijo, pero otras reglas (como redirecciones internas) pueden interferir. Prueba y simplifica.
Task 6: Valida si Apache (.htaccess) está involucrado (común en stacks compartidos)
cr0x@server:~$ sudo apachectl -M 2>/dev/null | grep -E "rewrite|security2"
rewrite_module (shared)
security2_module (shared)
Significado: Apache tiene mod_rewrite y mod_security habilitados. Incluso si estás detrás de un proxy, Apache puede aplicar estas reglas en el origen.
Decisión: Revisa los logs de auditoría de mod_security y los bloques de hardening en .htaccess para rutas wp-admin.
Task 7: Revisa el log de auditoría de mod_security por un hit de regla
cr0x@server:~$ sudo grep -n "admin-ajax.php" /var/log/modsec_audit.log | tail -n 8
--e3f2b9c7-H--
Message: Access denied with code 403 (phase 2). Matched phrase "select" at ARGS:query. [id "942100"] [msg "SQL Injection Attack Detected"] [severity "CRITICAL"]
Apache-Handler: proxy:unix:/run/php/php8.2-fpm.sock|fcgi://localhost/var/www/html/wp-admin/admin-ajax.php
Significado: OWASP CRS piensa que tu petición contiene patrones de SQLi. El parámetro query lo está disparando—podría ser un término de búsqueda, un filtro o la carga de un plugin.
Decisión: No deshabilites mod_security globalmente. Crea una exclusión estrecha para ese ID de regla en esa acción/parámetro específico, o cambia el plugin para codificar/renombrar campos.
Task 8: Confirma si WordPress ve la petición en absoluto (accesos PHP-FPM o logs de lentitud)
cr0x@server:~$ sudo tail -n 20 /var/log/php8.2-fpm.log
[27-Dec-2025 10:13:02] WARNING: [pool www] child 18223 said into stderr: "Primary script unknown"
Significado: “Primary script unknown” suele ser SCRIPT_FILENAME mal configurado o un mapeo try_files/fastcgi_param incorrecto. Esto a menudo produce 404/403/400 raros dependiendo del comportamiento del servidor.
Decisión: Arregla los parámetros fastcgi para que /wp-admin/admin-ajax.php se mapee a la ruta real del sistema de ficheros.
Task 9: Valida que el archivo exista y los permisos sean correctos
cr0x@server:~$ sudo ls -l /var/www/html/wp-admin/admin-ajax.php
-rw-r--r-- 1 www-data www-data 4496 Nov 8 12:10 /var/www/html/wp-admin/admin-ajax.php
Significado: El archivo existe y es legible. Los permisos no son el problema.
Decisión: Sube en la pila: reglas de configuración, WAF, mod_security, autenticación de WordPress.
Task 10: Comprueba si la IP del cliente está bloqueada localmente (fail2ban / firewall)
cr0x@server:~$ sudo fail2ban-client status
Status
|- Number of jail: 2
`- Jail list: sshd, nginx-http-auth
cr0x@server:~$ sudo fail2ban-client status nginx-http-auth
Status for the jail: nginx-http-auth
|- Filter
| |- Currently failed: 2
| `- Total failed: 18
`- Actions
|- Currently banned: 1
`- Banned IP list: 198.51.100.24
Significado: La IP del navegador está baneada. Eso puede generar 403 antes de que WordPress se ejecute.
Decisión: Desbanea si es un falso positivo, y ajusta la cárcel. También confirma que la IP real del cliente no esté siendo sustituida por la dirección de un proxy.
Task 11: Desbanear una IP (con cuidado) y reintentar
cr0x@server:~$ sudo fail2ban-client set nginx-http-auth unbanip 198.51.100.24
1
Significado: “1” significa éxito (una IP desbaneada).
Decisión: Vuelve a probar la petición AJAX; si tiene éxito, has confirmado el bloqueador. Luego ajusta los filtros para que las ráfagas normales de admin-ajax no parezcan stuffing de credenciales.
Task 12: Verifica si WordPress está devolviendo 403 por fallo de nonce
cr0x@server:~$ sudo -u www-data wp option get home --path=/var/www/html
https://example.com
cr0x@server:~$ sudo -u www-data wp option get siteurl --path=/var/www/html
https://example.com
Significado: Home y siteurl son correctos. Si estos son incorrectos (http vs https, dominio antiguo), WordPress puede generar URLs AJAX o nonces que no coinciden con el entorno de la petición.
Decisión: Si los valores no coinciden con la realidad, arréglalos. Luego limpia caches y vuelve a probar.
Task 13: Activa el registro de depuración de WordPress brevemente (y léelo)
cr0x@server:~$ sudo -u www-data bash -lc "grep -n \"WP_DEBUG\" /var/www/html/wp-config.php | head"
90:define('WP_DEBUG', false);
91:define('WP_DEBUG_LOG', false);
cr0x@server:~$ sudo -u www-data bash -lc "sed -i \"90,95{s/false/true/}\" /var/www/html/wp-config.php"
cr0x@server:~$ sudo -u www-data tail -n 30 /var/www/html/wp-content/debug.log
[27-Dec-2025 10:16:09 UTC] PHP Notice: check_ajax_referer failed for action=save_widget in /var/www/html/wp-content/plugins/example/plugin.php on line 211
Significado: El plugin está fallando la verificación de nonce. Eso es un rechazo a nivel de WordPress, no un bloqueo del servidor web/WAF.
Decisión: Arregla el caché de páginas que incrustan nonces, asegura cookies y configuraciones SameSite correctas, y confirma que la petición AJAX incluya el campo nonce que el plugin espera.
Task 14: Confirma si la respuesta viene de WordPress o de una capa de seguridad (cabeceras y cuerpo)
cr0x@server:~$ curl -i -s https://example.com/wp-admin/admin-ajax.php?action=does_not_exist | head -n 20
HTTP/2 200
date: Sat, 27 Dec 2025 10:17:01 GMT
content-type: text/html; charset=UTF-8
server: nginx
x-powered-by: PHP/8.2.10
0
Significado: WordPress responde con 0 para acciones no manejadas. Eso es relativamente normal, y te dice que la petición llegó a WordPress correctamente.
Decisión: Si tu petición fallida devuelve una página HTML de marca del WAF, ese es un modo de fallo distinto a un 0 de WordPress o un error JSON.
Task 15: Revisa límites de tamaño de cuerpo del cliente en Nginx (disparadores de 400)
cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -n "client_max_body_size" | head
210: client_max_body_size 1m;
Significado: 1 MB puede ser demasiado pequeño para algunas cargas de plugin (guardados de page builder, metadatos de medios, opciones serializadas grandes). Exceder el límite puede aparecer como 413, pero dependiendo de proxies/WAF puede degradarse a 400.
Decisión: Aumenta el límite para el sitio (o específicamente para wp-admin) si está justificado, y mantén los límites del WAF alineados.
Task 16: Valida CORS y manejo de Origin (AJAX cross-domain)
cr0x@server:~$ curl -i -s -X OPTIONS https://example.com/wp-admin/admin-ajax.php \
-H 'Origin: https://shop.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
HTTP/2 403
server: nginx
content-type: text/html
Significado: OPTIONS está siendo prohibido. Si estás haciendo peticiones cross-origin, tu servidor debe responder correctamente a las preflight requests.
Decisión: Evita llamadas cross-origin a admin-ajax (lo ideal), o permite explícitamente OPTIONS y establece cabeceras CORS correctas para los orígenes exactos.
Tres microhistorias corporativas desde producción
Microhistoria 1: El incidente causado por una suposición equivocada
Un equipo de marketing desplegó un plugin “spin-to-win” en un sitio WordPress de alto tráfico. Era cursi, pero convertía. El plugin usaba admin-ajax.php para todo: creación de sesión, validación de cupones y telemetría de “registrar esta impresión”. El desarrollador que aprobó asumió que admin-ajax era “interno” porque vivía bajo /wp-admin/.
Seguridad hizo lo que hace: desplegó una nueva política WAF que apretó el acceso a endpoints de administración, especialmente cualquier cosa bajo /wp-admin/ que no fuera una sesión autenticada. Había una allowlist para páginas de /wp-admin/ usadas por administradores reales, pero nadie pensó incluir admin-ajax porque “eso no es una página”.
A mediodía, las conversiones se desplomaron. El popup todavía aparecía, pero cada “giro” resultaba en una petición muerta. DevTools mostró 403. Llegaron tickets de soporte con el tono familiar del comercio moderno: “su sitio está roto y estoy enfadado”.
La solución llevó diez minutos una vez que las personas correctas dejaron de debatir y empezaron a probar. Se actualizó la regla WAF para permitir POSTs a /wp-admin/admin-ajax.php para acciones específicas usadas por ese plugin. También le aplicaron limitación de tasa adecuada y bloquearon el resto. Luego crearon una pequeña página de “inventario de endpoints AJAX” en el runbook, porque resultó que la mitad de la interacción del sitio dependía de admin-ajax.
La lección no fue “los WAF son malos”. La lección fue que el nombrado de rutas en WordPress es engañoso, y las suposiciones hacen que los incidentes se sientan personales.
Microhistoria 2: La optimización que salió mal
Un equipo de plataforma quería reducir la carga de PHP. Razonable. Notaron que admin-ajax.php representaba una gran parte de las peticiones y decidieron cachearlo en el borde para “usuarios anónimos” porque “la mayoría de esas llamadas son iguales”. Añadieron una regla CDN: cachear /wp-admin/admin-ajax.php con un TTL corto para peticiones sin cookies de sesión.
Funcionó en staging. También “funcionó” en producción el tiempo suficiente para convencer a todos de que era una victoria. Luego empezó la rareza: algunos usuarios recibieron respuestas obsoletas a acciones que debían ser únicas por visitante. Algunos vieron fallos en validaciones de cupones. El caso más divertido: un plugin devolvió un nonce en una respuesta AJAX, y el CDN gustosamente lo sirvió a todos durante 30 segundos.
Cuando alguien admitió que cachear admin-ajax era una idea arriesgada, el síntoma ya era una pila de informes: fallos de carrito, 403 aleatorios, “el botón no hace nada” y algunas preocupaciones de seguridad por clientes atentos. Nadie podía reproducir de forma fiable porque las respuestas cacheadas dependían de qué POP de borde tocabas y cuándo.
El rollback fue inmediato. La acción siguiente fue menos dramática pero más importante: reemplazaron las interacciones anónimas de alto volumen por endpoints REST diseñados para ser cacheables donde procediera, y dejaron de intentar engañar a WordPress cacheando un endpoint tan general.
Broma #2: cachear admin-ajax es como fotocopiar la tarjeta de acceso de la oficina y repartirla “por eficiencia”. Reduce fricción.
Microhistoria 3: La práctica aburrida pero correcta que salvó el día
Una gran empresa ejecutaba múltiples propiedades WordPress detrás del mismo WAF, la misma base Nginx y un conjunto de reglas mod_security compartido. Ya habían sufrido falsos positivos antes. Así que hicieron algo poco sexy: cada excepción WAF/mod_security tenía que estar ligada a (1) un endpoint específico, (2) un parámetro específico y (3) un ticket con un comando de reproducción.
Una tarde, las peticiones admin-ajax empezaron a fallar con 400 por una nueva función de un page builder. Los editores no podían guardar maquetaciones. Sin drama, solo dinero filtrándose silenciosamente. El equipo on-call ejecutó el comando curl de reproducción almacenado en el sistema de tickets (los mantenían en notas). Falló igual desde una IP limpia. Genial—ahora no están adivinando.
Revisaron los logs de auditoría de mod_security y encontraron una sola regla CRS disparando sobre un campo JSON cuya forma cambió recientemente. Debido a su política de “excepciones ajustadas solo”, no deshabilitaron todo el grupo de reglas. Escribieron una exclusión delimitada a esa acción y ese campo, con comentarios.
El outage fue breve. La postura de seguridad se mantuvo intacta. El postmortem fue corto porque la evidencia ya estaba capturada. Lo aburrido ganó. De nuevo.
Errores comunes: síntomas → causa raíz → solución
Estos son los que veo repetidamente, incluidos los que empiezan con “no cambiamos nada”. Alguien lo hizo. O algo se actualizó. O una caché expiró.
1) Síntoma: 403 con página de bloqueo de marca
Causa raíz: Protección de bots del CDN/WAF, limitación de tasa o reglas gestionadas de WordPress que bloquean /wp-admin/admin-ajax.php.
Solución: Crea una regla de allow para admin-ajax con restricciones: permitir solo los métodos HTTP necesarios, solo las acciones requeridas (si tu WAF puede inspeccionar cuerpo/query), y limitar en vez de bloquear. Si los desafíos de bots están activados, excluye admin-ajax de los desafíos.
2) Síntoma: 403 solo para usuarios autenticados, los anónimos funcionan
Causa raíz: Problemas de cookies o SameSite tras cambios de HTTPS/proxy; las acciones autenticadas requieren cookies correctas. A veces el navegador deja de enviar cookies por SameSite o desajuste de dominio.
Solución: Asegura esquema y dominio consistentes para home/siteurl. Confirma que las cookies están establecidas para el dominio correcto, y que los proxies reenvían X-Forwarded-Proto adecuadamente para que WordPress sepa que está en HTTPS.
3) Síntoma: 403 tras cambio de “endurecer wp-admin”
Causa raíz: Regla Nginx/Apache niega todo /wp-admin/ y olvidó eximir admin-ajax.php y admin-post.php.
Solución: Añade una excepción específica para = /wp-admin/admin-ajax.php (y prueba la precedencia de ubicaciones). Si restringes wp-admin por IP, asegúrate de que admin-ajax no quede IP-restringido para acciones públicas de las que dependes.
4) Síntoma: 400 sin cuerpo de respuesta útil
Causa raíz: Cuerpo de petición rechazado por mod_security o por límites de tamaño (cabeceras/cuerpo). A veces un proxy está truncando el cuerpo, causando “petición malformada” aguas abajo.
Solución: Revisa el log de auditoría de mod_security primero. Luego revisa límites de Nginx/Apache y del proxy inverso. Alinea los límites desde borde → LB → origen. No “subas todo a infinito”; fija un techo realista.
5) Síntoma: 200 OK pero el cuerpo de respuesta es “0”
Causa raíz: La acción solicitada no está registrada, o el manejador salió temprano. A veces estás golpeando el sitio equivocado tras una migración (vhost incorrecto), y WordPress responde pero no tu plugin.
Solución: Confirma que el JS usa la URL AJAX correcta. Confirma que el plugin registra los hooks wp_ajax_. Revisa que no estés bloqueado por plugins must-use o carga condicional según el entorno.
6) Síntoma: 403 solo en ciertos parámetros (términos de búsqueda, filtros)
Causa raíz: Falso positivo del WAF/mod_security en el contenido de la carga (patrones SQLi/XSS) o blobs base64/serializados.
Solución: Excepción estrecha: ID de regla + endpoint + parámetro. O cambia el plugin para enviar JSON y sanitizar/codificar campos de forma más predecible.
7) Síntoma: 403 intermitente, a menudo “funciona después de refrescar”
Causa raíz: Página cacheada contiene nonces caducados o desajustados; la petición del usuario usa un nonce que WordPress rechaza.
Solución: Excluye páginas con nonces específicos de usuario del cacheo completo, o usa ESI/fragment caching. También verifica la sincronización horaria en los servidores; las ventanas de nonce dependen de tiempo consistente.
8) Síntoma: admin-ajax falla solo desde rangos IP de oficina/VPN
Causa raíz: La NAT corporativa está en una lista de amenazas, o la limitación de tasa se dispara por muchos usuarios detrás de una sola IP.
Solución: Ajusta la limitación de tasa del WAF por sesión/cookie donde sea posible, o allowlista rangos IP corporativos con monitoreo y controles compensatorios.
Listas de verificación / plan paso a paso
Paso a paso: arreglar admin-ajax.php 400/403 sin empeorarlo
- Captura una petición fallida desde DevTools (método, cabeceras, payload, cabeceras/cuerpo de respuesta).
- Reproduce con curl usando el mismo método y parámetros. Si no puedes reproducir, te faltan cookies o un nonce.
- Decide la capa:
- ¿Cabeceras WAF/CDN presentes? Empieza ahí.
- ¿Nginx “access forbidden by rule”? Empieza por la configuración.
- ¿Hit en mod_security audit log? Empieza con el ID de regla y el parámetro.
- ¿Depuración de WordPress muestra fallo de nonce/capacidad? Arregla app/caché/auth.
- Prueba el comportamiento del origen omitiendo el CDN y volviendo a probar (patrón Task 2).
- Revisa bloqueos locales (fail2ban/firewall). No pierdas una hora depurando una IP baneada.
- Aplica el cambio más pequeño y seguro:
- Prefiere allowlistear una acción/parámetro específico sobre permitir todo admin-ajax.
- Prefiere limitar por tasa en lugar de bloqueos totales.
- Prefiere endpoints REST de WordPress para tráfico público tipo API.
- Vuelve a probar desde múltiples redes (oficina, hotspot móvil, servidor) para validar que la solución no sea específica de una clase IP.
- Añade monitoreo: rastrea tasas 4xx de admin-ajax en borde y origen por separado. Una métrica combinada oculta al culpable.
- Escribe la nota en el runbook: qué lo bloqueó, qué log lo probó, qué cambio lo solucionó y qué habría detectado el problema antes.
Checklist operativo: evita que admin-ajax sea tu fábrica de outages ocultos
- Inventaría las acciones AJAX de alto volumen (especialmente las
nopriv). - No cachees
/wp-admin/admin-ajax.phpen capas CDN o proxy. - Asegura que
/wp-admin/admin-ajax.phpsea accesible incluso si wp-admin está restringido por IP (a menos que realmente quieras romper funciones del front-end). - Alinea límites de cuerpo/cabeceras entre borde, LB y origen.
- Mantén la sincronización horaria (NTP) estable en todos los nodos para evitar rarezas con nonces.
- Para WAF/mod_security: usa excepciones ajustadas y rastrea qué plugin/acciones las requieren.
Preguntas frecuentes
¿Por qué admin-ajax.php devuelve 403 aunque el sitio cargue bien?
Porque tu página HTML es cacheable y pública, pero admin-ajax es interactivo y a menudo basado en POST. Reglas WAF, restricciones de IP, mod_security o cheques de nonce/cookie pueden bloquearlo sin afectar las cargas de página normales.
¿Es seguro allowlistear /wp-admin/admin-ajax.php en el WAF?
Puedes, si lo haces con restricciones. Allowlistear la ruta sin condiciones es una invitación al abuso. Prefiere: permitir solo métodos requeridos, aplicar limitación de tasa y, si es posible, permitir solo valores action específicos.
¿Por qué veo “0” como cuerpo de respuesta?
Ese es el output por defecto de WordPress cuando una acción AJAX no está manejada o sale sin imprimir. A menudo significa que la petición llegó a WordPress, pero el manejador del plugin no se ejecutó (acción incorrecta, plugin no cargado o expectativas de autenticación no cumplidas).
¿Pueden los plugins de caché causar 403 en admin-ajax?
Indirectamente, sí. Pueden cachear páginas que incrustan nonces o valores dependientes de sesión, provocando fallos de nonce que se manifiestan como 403 cuando el manejador AJAX usa check_ajax_referer. También pueden interferir con cookies o compresión en casos límite.
¿Debería cambiar de admin-ajax al REST API de WordPress?
Para interacciones públicas y de alto volumen: sí, por lo general. Los endpoints REST son más fáciles de asegurar, observar y cachear correctamente. Conserva admin-ajax para flujos administrativos heredados a menos que estés listo para refactorizar.
¿Cuál es la forma más rápida de saber si mod_security está bloqueando admin-ajax?
Mira el log de auditoría de mod_security por un hit de regla con la marca temporal que coincide con la petición fallida. Si ves un ID de regla y un parámetro coincidente, tienes la prueba.
¿Por qué falla solo en ciertas consultas de búsqueda o valores de filtro?
Las reglas de seguridad inspeccionan el contenido de la carga. Algunas entradas de usuario parecen un ataque (o coinciden con firmas). Ajusta con una excepción estrecha o cambia el formato/codificación de la petición.
¿Por qué funciona desde mi red doméstica pero no desde la oficina?
Las oficinas a menudo agrupan muchos usuarios detrás de una misma IP NAT. La limitación de tasa y la detección de bots puede penalizar esa IP compartida. Además, proxies corporativos pueden alterar cabeceras y disparar reglas más estrictas.
¿Pueden ajustes erróneos de home/siteurl en WordPress causar fallos en admin-ajax?
Sí. Si WordPress cree que corre en un esquema o dominio distinto, puede generar URLs AJAX y nonces que no coinciden con el contexto del navegador. Corrige esas opciones y verifica las cabeceras del proxy.
Próximos pasos que realmente funcionan
admin-ajax.php 400/403 no es misterioso. Es un problema de cadena de custodia: necesitas probar qué componente devolvió el estado y qué regla o cheque se disparó. Una vez que lo hagas, las soluciones son sencillas—y puedes mantenerlas acotadas en vez de abrir huecos en tu seguridad.
Haz lo siguiente:
- Elige una petición fallida y repródúcela con curl (incluyendo cookies si es necesario).
- Omite el CDN para comparar comportamiento borde vs origen.
- Lee los logs correspondientes (WAF → servidor web → mod_security → PHP/WordPress) en ese orden.
- Aplica la allowlist o la corrección de código/caché más pequeña que resuelva la causa raíz.
- Añade un único panel de control: tasa 4xx de admin-ajax dividida por borde vs origen. Quieres que el siguiente incidente sea aburrido.
Cita de ingeniería: “La esperanza no es una estrategia.” — Gen. H. Norman Schwarzkopf