Buzeli
buzeliSoluções Digitais
WordPress

wp-login taking 1 minute: how auth_basic behind a CDN creates an invisible 401 loop in nginx

Published on April 23, 2026

The symptom that made no sense

During a performance audit on a high-traffic news portal, the slowness at wp-login.php was the first anomaly encountered. The TTFB (Time To First Byte) via browser measured around 1 minute. The same request via curl directly to the server returned in 2 milliseconds.

When localhost responds in 2ms and the browser waits 1 minute, the problem is not the server. It's in the path between the CDN and the server — or in how the server responds when the request comes through the CDN.

The server stack: nginx + PHP-FPM in Docker containers, no WAF, with a CDN in front. The nginx was doing real IP passthrough via set_real_ip_from and real_ip_header X-Forwarded-For to extract the real visitor IP from the CDN headers.

The acl.conf and satisfy any

The investigation led to the WordPress configuration file in nginx. The wp-login.php location included a shared access control file:

Copy
# /home/developer/webserver/common/wpcommon.conf
location = /wp-login.php {
  include common/acl.conf;
  limit_req zone=one burst=1 nodelay;
  include fastcgi_params;
  fastcgi_pass php;
}

The acl.conf file contained:

Copy
# /home/developer/webserver/common/acl.conf
satisfy any;
auth_basic "Restricted Area";
auth_basic_user_file htpasswd-ee;
allow 127.0.0.1;
allow 172.28.5.0/24;
allow 198.51.100.0/24;
allow 10.0.0.0/8;
deny all;

At first glance, the configuration looks reasonable: either the IP is in the whitelist (allow), or the visitor presents valid credentials (auth_basic). Either one is sufficient — that is what satisfy any does.

What satisfy any does in practice

The nginx satisfy any directive defines that a location can be accessed if any of the configured authentication/authorization mechanisms is satisfied. In the context of this acl.conf:

If the IP is in the whitelist (allow): access granted without needing a password.

If the IP is not in the whitelist: nginx returns 401 and requests basic credentials (auth_basic).

If the visitor provides valid credentials on the 401: access granted.

The design assumes that known IPs (admins, office, VPN) get direct access, and unknown IPs can access with a username and password. It works perfectly when nginx sees the real visitor IP.

The problem: the CDN was not in the whitelist

The nginx was configured with set_real_ip_from and real_ip_header X-Forwarded-For to extract the real IP from the CDN-sent header. When the configuration works correctly, nginx replaces the source IP with the real visitor IP before evaluating the access rules.

But there was a subtle difference between the two tests:

curl local (127.0.0.1): Direct connection to nginx without going through the CDN. The source IP is 127.0.0.1, which is in the whitelist. Result: 200 in 2ms.

Browser via CDN: CDN forwards the request to the server. The nginx receives the real visitor IP via X-Forwarded-For — and that IP is not in the whitelist.

The nginx was doing the right thing: identifying the real visitor IP. The problem is that the real visitor IP was not in the whitelist. So it returned 401.

A plain 401 should not cause a 1-minute wait. The timeout appeared because the CDN, upon receiving a 401 with WWW-Authenticate, tried to negotiate the authentication — waiting for a response that never came in the expected format. The result visible to the user was a TTFB of ~60 seconds until the CDN gave up or forwarded the error.

Comparison: before and after

The behavior can be verified directly by comparing both paths:

Copy
# Direct test to the server (bypasses CDN)
curl -sv -o /dev/null -w "TTFB: %{time_starttransfer}s\n" \
  http://127.0.0.1/wp-login.php
# Result: TTFB: 0.002s — HTTP 200

# Test via CDN (simulates the browser)
curl -sv -o /dev/null -w "TTFB: %{time_starttransfer}s\n" \
  -H "Host: client-example.com" \
  https://client-example.com/wp-login.php
# Result: TTFB: ~1.083s — HTTP 401

The 1.083s in the CDN test is already faster than the 1 minute in the browser because curl does not wait for authentication negotiation — it returns immediately upon receiving the 401. The browser, on the other hand, tries to render the authentication popup and waits while the CDN processes the response.

The fix options

There are three approaches, depending on how the client wants to manage access to wp-login.php:

Option 1: add the operator's IP to the whitelist

Copy
# acl.conf — add specific IP for immediate access
satisfy any;
auth_basic "Restricted Area";
auth_basic_user_file htpasswd-ee;
allow 127.0.0.1;
allow 172.28.5.0/24;
allow 203.0.113.45;   # operator/office IP — RFC5737 example
deny all;

Works for those accessing from known fixed IPs. Does not solve for users with dynamic IPs or remote access without VPN.

Option 2: remove acl.conf from wp-login if protection is via plugin

Copy
# wpcommon.conf — without acl.conf, protection delegated to WordPress
location = /wp-login.php {
  limit_req zone=one burst=1 nodelay;
  include fastcgi_params;
  fastcgi_pass php;
}

Suitable when WordPress already has plugin-based protection (Limit Login Attempts, Wordfence, etc.). Eliminates the double authentication layer that caused the conflict.

Option 3: verify htpasswd-ee has credentials and the client uses auth_basic intentionally

In some cases, auth_basic is intentional — the client wants any access to wp-login.php to require a password before reaching PHP. In that case, the solution is to confirm the htpasswd file has valid credentials and that the CDN is configured to forward basic authentication to the origin.

The anti-pattern: satisfy any with a CDN

The satisfy any + auth_basic + IP whitelist pattern is effective on servers with direct access. It breaks silently when there is a CDN in front for a specific reason: the CDN centralizes access to the origin from a small set of outbound IPs. The real visitor has one IP, but nginx sees the CDN IP — or the real IP extracted from X-Forwarded-For, which rarely matches the whitelist created for internal IPs.

Every time you put a CDN in front of a configuration that uses allow/deny by IP, you need to review the access rules. What worked with direct access can become invisible or inaccessible via CDN.

This pattern is especially dangerous because it generates no explicit error. The server responds with 401, the CDN processes it normally, and the high TTFB looks like a performance problem — not a configuration issue. Without comparing localhost vs CDN, the real cause remains invisible.

How to diagnose on other servers

Copy
# 1. Compare TTFB direct vs via CDN
curl -sv -w "\nTTFB: %{time_starttransfer}s\n" http://127.0.0.1/wp-login.php
curl -sv -w "\nTTFB: %{time_starttransfer}s\n" https://your-site.com/wp-login.php

# 2. Check HTTP code returned via CDN
curl -sv -o /dev/null https://your-site.com/wp-login.php 2>&1 | grep "< HTTP"

# 3. Find acl includes in nginx configurations
grep -r "include.*acl" /home/developer/webserver/ --include="*.conf"
grep -r "satisfy" /home/developer/webserver/ --include="*.conf"

# 4. Check which IP nginx sees in CDN requests
grep "wp-login" /var/log/nginx/access.log | tail -20
# The IP in the log should be the real visitor IP, not the CDN IP

If the HTTP code via CDN is 401 and the TTFB is high, the problem is satisfy any waiting for a response from the CDN. If the IP in the nginx log is the CDN IP (not the real visitor IP), set_real_ip_from is not configured — and the real IP will never reach the evaluation of the allow/deny rules.

The lesson

High TTFB at wp-login.php is frequently treated as an overloaded server, slow database, or heavy plugin problem. Those are the usual suspects — which is why the real cause takes time to surface. When localhost responds in 2ms and the CDN responds in 1 minute, the server is innocent. The investigation starts at the proxy configuration layer.

satisfy any is not a bug. It is a powerful tool that assumes nginx sees the real client IP. With a CDN in front, that assumption can break — and the protection becomes a silent block.