When WordPress sits behind CloudFront and an ALB, the REMOTE_ADDR that PHP sees is not the visitor’s address. The common fix is to parse X-Forwarded-For yourself in wp-config.php and take the “Nth entry from the end”. This site did exactly that, and as a result visitors could set their own IP to whatever they liked. The reason is that Apache in the official WordPress image has already processed and rewritten this header with mod_remoteip. This article takes the problem apart by following what actually happens to one request, then gives the fix, switching to CloudFront-Viewer-Address, and the preconditions it depends on.
X-Forwarded-For along the request chain
Suppose the visitor’s real address is 198.51.100.10 and the visitor adds a forged header to the request. All addresses below are documentation example addresses:
GET / HTTP/1.1
Host: www.example.com
X-Forwarded-For: 203.0.113.66 ← written by the visitor
Each hop handles it in turn:
| Hop | Action | X-Forwarded-For afterwards |
|---|---|---|
| CloudFront | Appends the visitor address it sees on the TCP connection | 203.0.113.66, 198.51.100.10 |
| ALB (default append mode) | Appends the address of the client connected to it, i.e. the CloudFront edge node | 203.0.113.66, 198.51.100.10, 192.0.2.199 |
| Apache mod_remoteip | See the next section | 203.0.113.66, 198.51.100.10 |
The first two steps are documented CloudFront and ALB behavior. Looking only at those two, “the second-to-last entry is the visitor” seems right: the last entry is the edge node, the second-to-last is the visitor address appended by CloudFront, and the visitor cannot forge it. That is what this site originally wrote:
// Broken: parses X-Forwarded-For a second time and takes the second-to-last entry
$xff = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
$_SERVER['REMOTE_ADDR'] = $xff[max(0, count($xff) - 2)];
mod_remoteip has already been through it
The official wordpress:*-apache images enable mod_remoteip with this configuration:
RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 10.0.0.0/8
RemoteIPInternalProxy 172.16.0.0/12
RemoteIPInternalProxy 192.168.0.0/16
RemoteIPInternalProxy 169.254.0.0/16
RemoteIPInternalProxy 127.0.0.0/8
According to the Apache documentation, mod_remoteip processes this header from right to left. When it reaches an untrusted address it stops, uses that address as the client address, and rewrites the header to “the remaining addresses not yet confirmed”. If every address is trusted, it removes the header entirely.
Apply this to the example above: the peer connected to Apache is the ALB, whose address is in a private VPC range and counts as a trusted internal proxy. The rightmost entry it supplies, 192.0.2.199, is the edge node’s public address, which is not on the trusted list, so processing stops there. The result:
- the
REMOTE_ADDRin Apache and PHP is192.0.2.199, the CloudFront edge node; - the
X-Forwarded-Forpassed to PHP has been rewritten to203.0.113.66, 198.51.100.10; the rightmost entry has been “used up”.
When PHP then takes the second-to-last entry, it gets 203.0.113.66, which is exactly the value the visitor forged. When the visitor does not send the header, only 198.51.100.10 remains after the rewrite, max(0, count - 2) yields index 0, and the result is correct. So normal testing passes every time, and it only goes wrong when someone forges the header deliberately.
Earlier, when there was only an ALB and no CloudFront, the code took the last entry and had the same problem: mod_remoteip had already used up the visitor address appended by the ALB, and the remaining last entry was again one the visitor wrote. The conclusion: when Apache already runs mod_remoteip in front of PHP, the X-Forwarded-For that PHP sees contains only the part the visitor controls, and it should not be parsed again.
The fix: use CloudFront-Viewer-Address
CloudFront can add a CloudFront-Viewer-Address header to origin requests. Its value is the visitor’s IP plus source port, for example 198.51.100.10:46532. It does not appear automatically; the origin request policy has to include it. The managed policy AllViewerAndCloudFrontHeaders-2022-06 includes it, or you can add it on its own in a custom origin request policy. This header can only be put in an origin request policy, not in a cache policy.
In wp-config.php (this site injects it through the official image’s WORDPRESS_CONFIG_EXTRA), strip the trailing port:
if (!empty($_SERVER['HTTP_CLOUDFRONT_VIEWER_ADDRESS'])) {
$_SERVER['REMOTE_ADDR'] = preg_replace('/:\d+$/', '', $_SERVER['HTTP_CLOUDFRONT_VIEWER_ADDRESS']);
}
The regex removes only the last “colon followed by digits”, so the colons within an IPv6 address are not affected. This code runs in wp-config.php, before plugins load, so rate limiting, logging and comment records in plugins all receive the corrected address.
Why this header can be trusted
Switching to another header name is not safe by itself. Two preconditions must both hold:
- The value is generated by CloudFront. The AWS documentation states that this header contains the visitor address and port as seen by CloudFront. This site’s README records the conclusion that CloudFront overwrites a header of the same name sent by the visitor. We did not find a sentence in the AWS documentation that explicitly says it is overwritten, so before going live we recommend confirming it once in your own environment with a temporary page that echoes the request headers.
- The origin accepts requests only from CloudFront. Otherwise an attacker can bypass CloudFront, connect to the ALB directly and write their own
CloudFront-Viewer-Address. On this site, the ALB rule matches the Host and also requires a secret header that only CloudFront adds; requests without it fall through to the listener’s default 404.
Enable the PHP line above only in environments where condition 2 holds. In this site’s CDK code, the line is generated only when a CloudFront distribution is configured.
How to verify
Request the site with a forged header, then check which address WordPress recorded, for example by posting a comment on a test post and looking at the commenter IP in the admin:
curl -s -H 'X-Forwarded-For: 203.0.113.66' https://www.example.com/ -o /dev/null
Before the fix, the admin shows 203.0.113.66; after the fix it should be your own egress address. Also confirm that bypassing CloudFront and connecting to the origin directly is rejected: a request to the ALB with the correct Host but without the secret header should get the default action’s response, not a WordPress page.
Limitations
- This line only changes
$_SERVER['REMOTE_ADDR']inside PHP. The Apache access log still records the edge node address thatmod_remoteipderived. - The approach is tied to CloudFront. If you later add another proxy layer in front or switch CDNs, you need to work through the whole chain again.
- An alternative is to have
mod_remoteiptrust CloudFront’s origin-facing address ranges, so that Apache derives the visitor address directly. The cost is keeping that address list up to date; this site did not adopt it. - The ALB attribute
routing.http.xff_header_processing.modecan be set toappend,preserveorremove. Changing it on a shared ALB affects every backend, so this site keeps the default.
Links
- CloudFront: Adding CloudFront request headers (CloudFront-Viewer-Address)
- CloudFront managed origin request policies (AllViewerAndCloudFrontHeaders-2022-06)
- How CloudFront handles X-Forwarded-For
- ALB X-Forwarded headers
- Apache mod_remoteip
- docker-library/wordpress (the image’s remoteip configuration)
- CloudFront: Restricting access to an ALB



