Why does Apache return 421 Misdirected Request behind nginx or a proxy after updating to 2.4.64?
Apache 2.4.64 returns 421 when a proxy sends no SNI for a non-default vhost. Tested fixes for nginx, HAProxy and Caddy, and what SSLVHostSNIPolicy changes.
Key points
- Since 2.4.64, Apache answers 421 when a TLS connection arrived without SNI and the request's
Hostselects any virtual host other than the default one for that address, unless the two vhosts have compatible TLS settings. Different certificate files are enough to make them incompatible. Up to 2.4.63 the same request was served. - The compatibility test itself did not change. The 2.4.64 fix for CVE-2025-23048 moved the existing check out of the branch that ran only when SNI was present, so connections with no SNI are now checked too. The log line says so:
AH02032: Hostname a.test (default host as no SNI was provided) and hostname b.test provided via HTTP have no compatible SSL setup. - Upgrading to 2.4.65 fixes nothing: that release only fixed CVE-2025-54090 in
RewriteCond expr. The fix is in the proxy: nginx needsproxy_ssl_server_name on;plusproxy_ssl_name $host;, HAProxy needssni req.hdr(host). On 2.4.66 and later,SSLVHostSNIPolicy authonlyis a server-side alternative. - Sending
$hostas SNI breaks again with pooled connections. With nginxkeepaliveon anupstreamblock, or a Caddytls_server_nameplaceholder, alternating hosts got 200, 421, 200, 421 in testing, on 2.4.63 as well, because a connection opened for one name was reused for another.
Apache returns 421 behind a proxy after 2.4.64 because the proxy opens its TLS connection without SNI, so mod_ssl does the handshake with the default (first) virtual host for that address and port, and then the Host header asks for a different virtual host with a different certificate. Up to 2.4.63 Apache let that pass whenever SNI was absent. From 2.4.64 it refuses with 421 Misdirected Request and logs AH02032 ... (default host as no SNI was provided). The fix belongs in the proxy: make it send SNI equal to the Host it forwards. For nginx that is two lines:
proxy_ssl_server_name on;
proxy_ssl_name $host;That is why the first site on the server keeps working while every other site breaks: requests for the default vhost match the handshake vhost and are never checked. Upgrading to 2.4.65 changes nothing, and the 2.4.66 directive SSLVHostSNIPolicy can relax the rule on the Apache side. Every row below was tested against Apache 2.4.63 and 2.4.65 with two name-based vhosts, a.test (first, so the default) and b.test, each with its own certificate file. The client asked the proxy for b.test.
Which fronts get the 421, and the fix for each#
| Front and configuration | SNI Apache received | 2.4.63 | 2.4.64 and later | Fix |
|---|---|---|---|---|
nginx proxy_pass https://apache; (defaults) | none | 200 | 421 | proxy_ssl_server_name on; + proxy_ssl_name $host; |
nginx proxy_ssl_server_name on; only | apache (the proxy_pass host) | 421 | 421 | add proxy_ssl_name $host; |
nginx proxy_pass https://10.201.13.2; with SNI on | none (IP literal) | 200 | 421 | proxy_ssl_name $host; (SNI is then sent) |
nginx upstream backend, SNI on, no proxy_ssl_name | backend | 421 | 421 | proxy_ssl_name $host; |
nginx SNI $host, but Host left at $proxy_host | b.test | 421 | 421 | proxy_set_header Host $host; |
nginx SNI $host, Host $host, no upstream block | b.test | 200 | 200 | none |
nginx SNI $host through an upstream with keepalive | whatever the reused connection carried | 421 on every host switch | same | see the keepalive section |
HAProxy 3.2 server ... ssl verify none | none | 200 | 421 | sni req.hdr(host) |
HAProxy 3.2 with sni req.hdr(host) | b.test | 200 | 200 | none |
Caddy 2.11.7 reverse_proxy https://apache (defaults) | apache | 200 from site A | 200 from site A | header_up Host {host} plus per-site tls_server_name |
Caddy with header_up Host {host} | apache | 421 | 421 | per-site tls_server_name |
| Plain client with no SNI (curl to the IP) | none | 200 | 421 | send SNI |
The bold cells are what 2.4.64 changed: every one of them is a connection with no SNI at all. A front that sent the wrong SNI got 421 on 2.4.63 too, because that case was checked before. People who hit the error for the first time on 2.4.64 are almost all in the first group, which is why the HestiaCP forum thread on this error was opened on 12 July 2025, two days after 2.4.64 was released: its nginx templates proxied to Apache over HTTPS without SNI. The same request against 2.4.64, 2.4.66 and 2.4.69 behaved exactly like 2.4.65.
What 2.4.64 actually changed#
The changelog lists the cause under CVE-2025-23048, "mod_ssl access control bypass with session resumption": "Configurations are affected when mod_ssl is configured for multiple virtual hosts, with each restricted to a different set of trusted client certificates (for example with a different SSLCACertificateFile/Path setting). In such a case, a client trusted to access one virtual host may be able to access another virtual host, if SSLStrictSNIVHostCheck is not enabled in either virtual host." Nothing in that text mentions proxies or SNI, which is why the upgrade surprised so many people.
The source explains the connection. In ssl_hook_ReadReq() in 2.4.63, the 421 lived inside if ((servername = SSL_get_servername(...))), the branch that only runs when the client sent SNI:
/* 2.4.63: only reached when SNI was sent */
if (r->server != handshakeserver
&& !ssl_server_compatible(sslconn->server, r->server)) {
... "Hostname %s provided via SNI and hostname %s provided"
" via HTTP have no compatible SSL setup" ...
return HTTP_MISDIRECTED_REQUEST;
}In 2.4.64 the same test sits after that branch, so it runs for every TLS request, and the log message gained a second form for the no-SNI case:
/* 2.4.64 and 2.4.65: reached with or without SNI */
if (r->server != handshakeserver
&& !ssl_server_compatible(sslconn->server, r->server)) {
... "Hostname %s %s and hostname %s provided via HTTP have no compatible SSL setup",
servername ? servername : handshakeserver->server_hostname,
servername ? "provided via SNI" : "(default host as no SNI was provided)", ...
return HTTP_MISDIRECTED_REQUEST;
}ssl_server_compatible() and the helpers it calls are byte-for-byte the same in 2.4.63 and 2.4.64. The test was not tightened; it was applied to connections it used to skip. It treats two vhosts as compatible only if all of these match:
SSLProtocol;- the client verification settings:
SSLVerifyClient,SSLVerifyDepth,SSLCACertificateFileandSSLCACertificatePath, plus the CA name list (SSLCADNRequestFile/Path); SSLCipherSuite, including the TLS 1.3 suites;- the list of certificate and key file names, compared as strings.
The last item is the one that matters behind a proxy. Two sites with their own certificates are never compatible, so every non-default site fails. The comparison is of paths, not contents: in the test, b.test pointing at a byte-identical copy of a.test's certificate under another file name still got 421. Pointing both vhosts at the same file, a certificate that covers both names, made every case in the table return 200 on every version.
Reading the log line#
Apache 2.4.65 logs the refusal at error level, so it is visible with default settings:
[ssl:error] [pid 10:tid 30] [client 10.201.0.1:56364] AH02032: Hostname a.test (default host as no SNI was provided) and hostname b.test provided via HTTP have no compatible SSL setupThe first name tells you which case you are in:
| First part of AH02032 | Meaning | Which version logs it |
|---|---|---|
Hostname a.test (default host as no SNI was provided) | No SNI. a.test is the default vhost that did the handshake | 2.4.64 and later only |
Hostname apache provided via SNI | SNI was sent but matched no ServerName, so the default vhost did the handshake | All versions |
Hostname a.test provided via SNI | SNI named one real site and Host another | All versions |
Any of the above ... for policy 'secure' | Same, on 2.4.66 or later, under the policy named | 2.4.66 and later |
The client receives the standard Apache error page: "The client needs a new connection for this request as the requested host name does not match the Server Name Indication (SNI) in use for this connection." The proxy passes it through unchanged, because to the proxy it is just a response.
2.4.65 changes nothing, 2.4.66 adds a switch#
2.4.65 was released on 23 July 2025 with a single change, the fix for CVE-2025-54090, "'RewriteCond expr' always evaluates to true in 2.4.64". The ssl_engine_kernel.c files of 2.4.64 and 2.4.65 are identical, and the tests confirmed the same 421s. Advice to "upgrade to 2.4.65" is about a different regression.
2.4.66 (4 December 2025) added SSLVHostSNIPolicy, global context only, default secure. The documentation states that the policy "also applies to TLS connections where an SNI extension is not sent during the handshake", and groups the compared settings into two families:
| Policy | Any vhost mismatch | Different certificate, key, protocol or cipher | Different client verification settings | No-SNI request for b.test (tested, 2.4.69) |
|---|---|---|---|---|
strict | blocked | blocked | blocked | 421, even with one shared certificate |
secure (default) | allowed | blocked | blocked | 421 |
authonly | allowed | allowed | blocked | 200 |
insecure | allowed | allowed | allowed | 200 |
The first three columns are the documentation's table. The default, secure, reproduces 2.4.64 behaviour. In the 2.4.66 source the comparison is a hash of each vhost's settings instead of a field-by-field check, and it covers more than 2.4.64 did: CRL files and checking mode, OCSP settings and every SSLOpenSSLConfCmd line are part of the client verification family.
SSLVHostSNIPolicy authonly is the server-side fix when the vhosts differ only in certificates, which is the usual shared-hosting layout. It still blocks the CVE-2025-23048 case, where vhosts trust different client CAs. insecure turns the check off, and the documentation warns that "such a configuration may be vulnerable to CVE-2025-23048". On 2.4.65 and older the directive does not exist and Apache refuses to start with Invalid command 'SSLVHostSNIPolicy', perhaps misspelled or defined by a module not included in the server configuration.
Settings that do not fix it#
SSLStrictSNIVHostCheck on turns the 421 into a 403. It refuses every connection without SNI, the default vhost included, with AH02033: No hostname was provided via SNI for a name based virtual host, on 2.4.63 and 2.4.69 alike. It is the right setting for the multi-CA layout in the CVE, not for a proxy.
A copy of the certificate under another name, as shown above. The comparison is of file names.
nginx: send the Host as SNI, and set Host too#
The nginx proxy module documents proxy_ssl_server_name with a default of off, and proxy_ssl_name with a default of $proxy_host: "By default, the host part of the proxy_pass URL is used." So out of the box nginx sends no SNI, and with SNI turned on it sends the proxy_pass host, which is an internal name, an upstream block name or nothing at all. All three cases appear in the table above, and they are the same ones that cause SSL_do_handshake() failures against stricter backends, covered in nginx SSL handshaking to upstream errors. The working shape, tested on nginx 1.27.5 and 1.30.6:
location / {
proxy_pass https://10.201.13.2:443; # an IP literal is fine here
proxy_ssl_server_name on;
proxy_ssl_name $host; # SNI = the site being requested
proxy_set_header Host $host; # and Host = the same name
}Three details from testing:
- An IP address in
proxy_passis not a problem onceproxy_ssl_nameis set. nginx skips SNI for an IP literal, but withproxy_ssl_name $hostit sentb.testand got a 200. With SNI on and noproxy_ssl_name, the IP literal still sent no SNI. - SNI and
Hostmust name the same vhost. With SNI$hostand nginx's defaultHost $proxy_host, Apache saw SNIb.testandHost: apache, which falls to the default vhost: 421 on every version. Watchproxy_set_headerinheritance: oneproxy_set_headerin alocationdiscards every one inherited from theserverorhttplevel, includingHost. proxy_ssl_verify onchecks the certificate againstproxy_ssl_name. With$host, the backend's certificate for each site must name that site, which is exactly what the Apache vhosts already serve.
The keepalive trap: 200, 421, 200, 421#
Sending $host as SNI assumes each TLS connection is used for one name. nginx's upstream connection cache does not know that. In ngx_http_upstream_keepalive_module.c, a cached connection is chosen by comparing the peer's socket address and nothing else. The SNI it was opened with is not considered. Since 1.29.7 that cache is also on by default: the upstream module documentation gives the default as keepalive 32 local and says "Since 1.29.7, by default the cache for connections is activated and these connections are not shared between different locations". The local part keeps connections apart per location, and an explicit keepalive N without local shares them across locations.
Requests for a.test, b.test, a.test, b.test, b.test, a.test through one location, SNI $host, against Apache 2.4.65:
| nginx setup | 1.27.5 | 1.30.6 |
|---|---|---|
proxy_pass https://apache; (no upstream block) | all 200 | all 200 |
upstream backend { server apache:443; }, no keepalive line | all 200 | 200 421 200 421 200 421 |
same upstream with keepalive 8; | 200 421 200 421 200 421 | 200 421 200 421 200 421 |
one server block per site, upstream without keepalive line | all 200 | all 200 |
one server block per site, upstream with keepalive 8; | 200 421 200 421 200 421 | 200 421 200 421 200 421 |
Apache 2.4.63 gave the same pattern, because the mismatch here is wrong SNI, not missing SNI. Apache's log showed it plainly: b.test ... 421 sni=a.test. Which request fails depends on which cached connection nginx picks, so the pattern follows the order in which users visit the sites rather than any one site.
proxy_pass to a hostname or IP without an upstream block is never cached (the module skips implicit upstreams), so it is safe. For an upstream block shared by several sites, use one of these:
- give each site its own
serverblock and, on 1.29.7 and later, leave the default (or writekeepalive 32 local;), so each site's connections stay in its own location; - or turn caching off for that upstream with
keepalive 0;.
On 1.27 and older, local does not exist and any keepalive line mixes sites. How the connection cache works in general is covered in keep-alive and connection pooling.
HAProxy: sni req.hdr(host)#
HAProxy 3.2 sends no SNI on a server ... ssl line unless told to, so it hits the 2.4.64 change exactly like nginx's defaults: 421 for b.test, 200 for a.test. The manual names the right expression for this job in its description of sni. It warns against forwarding the client's SNI in HTTP mode: "THIS MUST NOT BE USED FOR HTTPS, where req.hdr(host) should be used instead, since SNI in HTTPS must always match the Host field". So:
backend apache_sites
server s1 10.201.13.2:443 ssl verify required ca-file /etc/haproxy/backend-ca.pem sni req.hdr(host)HAProxy did not show the keepalive problem. The manual explains why: "By default, the SNI is assigned to the connection name for "http-reuse"", so an idle connection is reused only for a request that evaluates to the same SNI. The alternating sequence returned six 200s. With verify required, the name is also checked against the certificate; without sni, the manual adds, "the certificate's names are ignored". A Host header with a port in it would put the port into the SNI, so strip it if clients send one. The rest of HAProxy's server line options are in HAProxy configuration for HTTP reverse proxying.
Caddy: no 421 by default, but the wrong site#
Caddy 2.11.7 sends the upstream's host as SNI and, for an HTTPS upstream, also rewrites Host to it. The documentation says that since v2.11.0 this "is done automatically, so it is no longer necessary to explicitly override the Host header when proxying to HTTPS". Against Apache that pair is consistent (SNI apache, Host: apache), so there is no 421. Instead every request lands on the default vhost: the b.test request returned 200 with site A's page. This is the worst result in the table because nothing logs an error.
Restoring the client's host with header_up Host {host} produces the classic mismatch (SNI apache, Host: b.test) and a 421 on every version. tls_server_name accepts a request placeholder, and the documentation notes that "a clone of the HTTP transport config will be used on every request". In the test, tls_server_name {http.request.host} still reused connections across hosts: the alternating sequence gave 421, 200, 421, 200, 200, 421, with Apache logging a.test ... 421 sni=b.test. Both of these returned six 200s:
http://b.test {
reverse_proxy https://10.201.13.2 {
header_up Host {host}
transport http {
tls_server_name b.test
}
}
}One site block per name with a static tls_server_name is the reliable form. A single block with the placeholder plus keepalive off in the transport also worked, at the cost of a new TLS handshake per request. More on Caddy's upstream defaults is in Caddy as a reverse proxy.
Cloudflare and other load balancers#
Cloudflare was not tested here, so only its documentation is reported. The Origin Rules documentation says that a Host header override "will also update the Server Name Indication (SNI) value of the original request to the same value", and that an SNI override takes a static value only. A rule that sets one fixed SNI for a hostname pattern covering several Apache vhosts gives every vhost but one a mismatch, which is the third row of the log table. The general rule for any load balancer is the same test applied to its settings: what name does it put in the ClientHello, what Host does it forward, and do both select the same Apache vhost? A layer 4 load balancer that passes TLS through untouched forwards the client's own SNI and never causes this. That design is described in SNI-based routing.
This is not the HTTP/2 coalescing 421#
Browsers get 421s from a different mechanism: HTTP/2 connection coalescing, where a browser reuses one connection for several hostnames covered by the same certificate and the server refuses a name it cannot serve on that connection. RFC 9110 defines 421 for both: a request "directed at a server that is unable or unwilling to produce an authoritative response for the target URI", including one that "does not match the connection context over which the request was received". It adds that a client "MAY retry the request ... over a different connection". None of the proxies tested here retried; each passed Apache's 421 to the client. The coalescing case is covered in HTTP/2 and HTTP/3 through proxies. The 2.4.64 case is on the proxy-to-Apache hop, so browser retries never come into it.
Failure modes#
- Only the first site on the server works. No SNI from the proxy. The first vhost does the handshake and is never checked. Send SNI.
- Fixed with
proxy_ssl_server_name on, still 421. nginx now sends theproxy_passhost or theupstreamname. Addproxy_ssl_name $host;. - Every second request fails, or failures follow the order users visit sites in. A pooled connection carrying another site's SNI. See the keepalive section.
- No error, wrong website. SNI and
Hostagree on a name Apache does not know, so the default vhost answers. Typical of Caddy's defaults. - Worked after the fix, broke when client certificates were added to one site. The client verification family differs, which blocks even under
SSLVHostSNIPolicy authonly. That layout needs correct SNI, and per-site trust is discussed in mTLS through proxies. - 421 inside a container stack after updating the
httpdimage. The officialhttpd:2.4tag moved past 2.4.64. Pinhttpd:2.4.63only as a stopgap: it is the version without the CVE-2025-23048 fix.
Where this stops applying#
Tested on 30 October 2026 with the official httpd images 2.4.63 and 2.4.64 (OpenSSL 3.0.16), 2.4.65 (3.5.4), 2.4.66 (3.5.5) and 2.4.69 (3.5.7), two name-based vhosts on one *:443 listener and default mod_ssl settings apart from those named. Fronts were nginx 1.27.5 and 1.30.6, HAProxy 3.2.26 and Caddy 2.11.7, all on HTTP/1.x to Apache (nginx 1.27.5 used its default HTTP/1.0). Not tested: Apache's own mod_proxy as the front, HTTP/2 between proxy and Apache, Cloudflare, cloud load balancers, and distribution packages, which may backport the CVE fix into an older version number. Control panels that generate nginx and Apache configuration overwrite hand edits; change their templates.
Frequently asked questions#
Why do I get 421 Misdirected Request after updating Apache to 2.4.64?#
Your proxy connects to Apache over HTTPS without SNI, so Apache does the handshake with its default vhost, and since 2.4.64 it refuses a request whose Host selects another vhost with a different certificate. Make the proxy send SNI equal to the Host; in nginx, proxy_ssl_server_name on; and proxy_ssl_name $host;.
Does upgrading to Apache 2.4.65 fix the 421 error?#
No. 2.4.65 only fixed CVE-2025-54090, where RewriteCond expr always evaluated to true in 2.4.64. Its SNI code is identical to 2.4.64's. 2.4.66 and later add SSLVHostSNIPolicy authonly, which lets vhosts that differ only in certificates share a connection.
What does "default host as no SNI was provided" mean in AH02032?#
The TLS connection carried no server name, so mod_ssl used the default (first) vhost for the handshake, and the HTTP Host then picked a different vhost whose TLS settings do not match. The message form exists only in 2.4.64 and later.
Is SSLVHostSNIPolicy insecure safe to use?#
Not if any vhost restricts access by client certificate. It disables the check that fixes CVE-2025-23048, and the documentation says the configuration "may be vulnerable" to it. authonly covers the usual proxy case and keeps that protection.
Why does proxy_ssl_name $host give 421 on every other request?#
nginx reuses cached upstream connections by address only. A connection opened with SNI a.test gets reused for a b.test request. That happens with an explicit keepalive line, or by default on nginx 1.29.7 and later when several sites share one location. Use a separate server block per site with the default local cache, or keepalive 0;.
Will one certificate for all sites stop the 421?#
Yes, if every vhost points at the same certificate and key files. Apache compares the file names, so a copy under another path still counts as different. A shared certificate also leaves SSLProtocol, ciphers and client verification to match, and it only works for the names that certificate covers.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- Apache HTTP Server 2.4 changelog (2.4.64, 2.4.65, 2.4.66 entries)
- Apache httpd source, modules/ssl/ssl_engine_kernel.c at 2.4.63
- Apache httpd source, modules/ssl/ssl_engine_kernel.c at 2.4.65
- Apache httpd source, modules/ssl/ssl_engine_init.c at 2.4.66 (SNI policy hash)
- Apache HTTP Server 2.4 vulnerabilities (CVE-2025-23048, CVE-2025-54090)
- Apache mod_ssl documentation (SSLVHostSNIPolicy, SSLStrictSNIVHostCheck)
- nginx ngx_http_proxy_module (proxy_ssl_server_name, proxy_ssl_name)
- nginx ngx_http_upstream_module (keepalive, local, 1.29.7 default)
- HAProxy 3.2 configuration manual (sni, pool-conn-name)
- Caddy reverse_proxy directive (tls_server_name, Host for HTTPS upstreams)
- Cloudflare Origin Rules settings (Host header and SNI overrides)
- RFC 9110: HTTP Semantics, section 15.5.20 421 Misdirected Request
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.
More in reverse proxy configuration#
- App under /app/ behind a proxy: X-Forwarded-Prefix
- Caddy reverse proxy
- Custom error page when the backend is down: nginx and more
- Envoy listeners, routes and clusters
- nginx auth_request vs ForwardAuth: why the 302 fails
- gRPC through a reverse proxy
- HAProxy configuration for HTTP reverse proxying
- Health checks and upstream failover
- ingress-nginx retired: proxy annotation equivalents
- nginx proxy_pass and the trailing slash
- nginx redirect drops the port or scheme: which fix
- nginx as a reverse proxy: a complete configuration
- nginx vs HAProxy vs Envoy vs Caddy vs Traefik
- Sticky sessions and session affinity
- Traefik routers, services and middlewares
- WebSockets through a reverse proxy