The silent cf-connecting-ip bug when you put Cloudflare in front of Traefik
After putting Cloudflare in front, I found that rate limiting had stopped working in several projects, and in one of them resource ownership verification had broken without anyone noticing. The cause was a single line of code that still trusted x-real-ip when it no longer should. Here's the right getClientIp pattern for Node.

Last post in the series. After migrating the VPS to Cloudflare with DNS-01 and AOP, IP-based firewalling and token monitoring, I opened the logs for several projects to check that everything was responding properly and noticed something odd. Rate limiting on my APIs had stopped working the way it used to. One IP could make hundreds of requests without the limiter cutting it off. And in another project, the IP hashes used to verify ownership of anonymous resources were exactly the same for every visitor within a given time window.
It's a single bug, and it shows up in any Node.js project you deploy behind the Cloudflare → Traefik → app chain if it has logic that depends on the client's IP. The explanation takes a couple of paragraphs, but the fix is four lines. What matters is understanding why it happens, because you probably have it in your apps and don't know it.
What your app gets when you put CF in front of Traefik
The visitor sends a request from IP 1.2.3.4. The Cloudflare edge receives it, processes it, and forwards it to your origin with several extra headers.
cf-connecting-ip: 1.2.3.4
x-forwarded-for: 1.2.3.4
x-real-ip: 1.2.3.4 (si CF lo añade, depende de config)So far, so good. Traefik receives that request on the VPS. And this is where something happens that is correct from a security standpoint, but counterintuitive if you haven't run into it before.
To make x-forwarded-for trustworthy and not spoofable by the client, I configured Traefik's forwardedHeaders.trustedIPs with Cloudflare's official ranges. That tells Traefik any request coming from these IPs is from a trusted proxy, respect its XFF headers. And Traefik does the right thing, it preserves the XFF that came in, and also appends the immediate peer's TCP IP at the end, which is the Cloudflare edge IP, not the client's. It also overwrites x-real-ip with the immediate peer's IP.
So this is what reaches your app after Traefik:
cf-connecting-ip: 1.2.3.4 (intacta)
x-forwarded-for: 1.2.3.4, 162.158.x.x (cliente real, edge CF al final)
x-real-ip: 162.158.x.x (edge CF, sobrescrito por Traefik)If your code reads x-real-ip, it's reading the CF edge IP.
If your code reads the rightmost value of x-forwarded-for because someone taught you that the last element is the "most trustworthy" IP, it's reading the CF edge IP.
If your code reads the leftmost value of x-forwarded-for, it gets the real IP, but that strategy is vulnerable to spoofing if a hostile client injects its own XFF before touching your proxy.
The only 100% trustworthy and unambiguous header is cf-connecting-ip, because CF sets it at the edge and it can't be spoofed if you have AOP enabled (any TLS connection to the origin is signed by CF, there is no direct bypass).
What exactly breaks
Any code that depends on the client's real IP breaks.
IP-based rate limiting
The Cloudflare edge has a small number of public IPs, a few dozen total, that forward traffic for millions of visitors. Yes, on the Free plan you can leave a Rate Limit Rule on the Cloudflare edge itself, but that doesn't replace rate limiting at the origin, it complements it. If your rate limiter counts requests per IP and your IP is "the CF edge IP", then every visitor shares the same bucket. Result, the limiter treats 5000 legitimate visitors as a single entity and cuts them all off together, or it doesn't stop an attacker because legitimate traffic "doesn't fill the bucket".
Owner verification by ip_hash
This was the most serious case I found. In PasteBin I have a system where anonymous pastes don't require an account, but the creator can edit or delete what they created. Identification is done with a hash of the visitor's IP. If all anonymous visitors have the same IP, the CF edge IP, they all share the same hash and any guest can edit or delete any other guest's pastes. Without the fix, this was a real security bug.
Log HMAC by IP
In Time Capsule I have auditable logs signed with HMAC, where the IP is part of the HMAC input. Without the fix, all logs in that time range end up with exactly the same hash, which breaks the guarantee that the log uniquely identifies the actor. Audit trail ruined.
Internal IP blocks / blocklists
Any app that keeps its own banned IP list loses the ability to ban individuals, because it only sees a handful of CF IPs. It either blocks all of CF, catastrophic, or blocks nobody.
The right pattern for getClientIp
The fix is always the same, a function that prioritizes cf-connecting-ip and only falls back to x-real-ip or XFF if the first one isn't there. Something like this.
/**
* Obtiene la IP del cliente real cuando la app corre detras de
* Cloudflare + Traefik. Prioriza cf-connecting-ip porque es la unica
* cabecera no spoofeable mientras AOP este activo en el origen.
*/
export function getClientIp(req: { headers: Record<string, string | string[] | undefined> }): string {
// 1. Cloudflare Connecting IP. La pone CF en su edge.
// Con AOP activo en el origen, no es spoofeable via bypass directo al VPS.
const cf = headerValue(req, "cf-connecting-ip");
if (cf) return cf;
// 2. Fallback para entornos sin CF delante (dev local, staging sin CF, etc.).
const real = headerValue(req, "x-real-ip");
if (real) return real;
// 3. Ultimo recurso, X-Forwarded-For rightmost. Traefik mete la IP TCP del
// peer al final cuando la IP origen no esta en trustedIPs.
const xff = headerValue(req, "x-forwarded-for");
if (xff) {
const parts = xff.split(",").map(s => s.trim()).filter(Boolean);
if (parts.length) return parts[parts.length - 1];
}
return "unknown"; // o "0.0.0.0" si tu logica requiere una IP valida para hashear
}
function headerValue(req: any, name: string): string | undefined {
const v = req.headers[name];
if (Array.isArray(v)) return v[0]?.trim() || undefined;
return typeof v === "string" ? v.trim() : undefined;
}Three important notes on why the priority is set up this way.
cf-connecting-ip first, because it's the only header the client can't inject on its own when AOP is active. Without AOP, in theory someone who knows the VPS IP could send a direct request with a forged
cf-connecting-ip. With AOP, they can't even complete the TLS handshake.x-real-ip second, as a safety net for environments without CF in front of them, local development, a different proxy stack, and so on.
XFF rightmost last, because when Traefik doesn't have the source IP in
trustedIPsit does write the TCP IP into the last XFF slot, so the rightmost value is closest to the truth when the other two are missing.
Tests you should have
This function is worth testing properly. If you mess it up, it doesn't fail loudly, it fails silently. Four minimum tests.
describe("getClientIp", () => {
it("prioriza cf-connecting-ip sobre todo", () => {
expect(getClientIp({ headers: {
"cf-connecting-ip": "1.2.3.4",
"x-real-ip": "5.6.7.8",
"x-forwarded-for": "9.10.11.12"
}})).toBe("1.2.3.4");
});
it("cae a x-real-ip si no hay cf-connecting-ip", () => {
expect(getClientIp({ headers: {
"x-real-ip": "5.6.7.8",
"x-forwarded-for": "9.10.11.12"
}})).toBe("5.6.7.8");
});
it("cae a XFF rightmost si no hay nada mas", () => {
expect(getClientIp({ headers: {
"x-forwarded-for": "1.2.3.4, 5.6.7.8, 9.10.11.12"
}})).toBe("9.10.11.12");
});
it("devuelve fallback si no hay ningun header", () => {
expect(getClientIp({ headers: {} })).toBe("unknown");
});
});Projects where I applied it
After the migration I audited my own projects and applied the fix where needed.
MyBox, in
request-utils.ts. Used by rate limiting and logs.Time Capsule, in
rate-limit.ts. Critical, the audit log HMAC was being signed with the CF edge IP.PasteBin, in
security.ts. Even more critical,ip_hashwas identifying owners, and without the fix all anonymous users shared the same identity.JMO Labs, patch applied in an earlier session because the behavior was already noticeable.
Projects that were already correct by design, and that I confirmed without changing anything.
ScamDetector, it was already prioritizing
cf-connecting-ip.
Projects where this doesn't apply because there's no server-side logic that depends on IPs.
CV Portfolio, Calculator, Ofusca, all frontend-only.
n8n, Infisical, ntfy, third-party software with its own proxy options. Some of them need specific config (
NTFY_BEHIND_PROXY=true, etc.). If you notice premature rate limiting in production, that's where to look.
How to spot it in your own stack
Three quick checks.
1. Inspect the incoming headers
A temporary debug endpoint that logs the relevant headers. Make a request from a known IP and see what arrives.
app.get("/_debug/ip", (req, res) => {
res.json({
cf: req.headers["cf-connecting-ip"],
real: req.headers["x-real-ip"],
xff: req.headers["x-forwarded-for"],
reqIp: req.ip, // Express con trust proxy
socket: req.socket.remoteAddress,
});
});If cf is your real IP and real is an IP from a Cloudflare range, they usually start with 162.158, 104.16-31, 172.64-71, then you have the problem and need the fix.
2. Look at rate limiting in production
If your rate limit is set to, say, 60 requests per minute and it never cuts anyone off even though traffic is high, be suspicious. Run an ab or equivalent from an IP outside trusted_ips against an endpoint and see whether request 61 gets a 429. If it doesn't, there's a good chance the counter is aggregating all traffic under a single virtual IP.
3. Search for the pattern in your code
Grep for common names, x-real-ip, req.ip, getClientIp, getClientIP, getIp, real_ip, remote_addr. In each case, check whether the first thing it looks at is cf-connecting-ip or not.
Why cf-connecting-ip is trustworthy with AOP
The reasonable objection to trusting an HTTP header is anyone can send one. And that's true, outside a controlled proxy, any client can forge an HTTP header.
What makes cf-connecting-ip trustworthy in my setup is the combination with AOP. For a request to reach my app, it first has to pass Traefik's TLS handshake. And that handshake requires a client cert signed by Cloudflare's CA. Only Cloudflare presents that cert. If someone tries to bypass CF and connect directly to the VPS, the handshake fails. So if a request reaches my app, it came through CF, and that means the cf-connecting-ip header was set by CF, because CF always sets it and overwrites the client's value if the client sent one. The client can't reach your app without going through CF, and CF normalizes the header.
Without AOP the guarantee is weaker, but still reasonable, because you still need to go through CF to avoid Traefik's generic handshake. With AOP it's mathematically trustworthy.
Closing
With this post, I wrap up the series. What started as "I'm going to put Cloudflare in front to hide the VPS IP" turned into six technical branches, one for each defensive layer or trap I found along the way. The final stack is reasonably solid without paying for Pro, it doesn't lock me into CF beyond a couple of hours of work if I decide to leave, and it gives me infrastructure where the origin literally listens to nobody except Cloudflare.
The cf-connecting-ip detail is the one I strongly recommend reviewing if you have your own projects behind Cloudflare. Three well-prioritized lines of code make any rate limiter, any hash-based owner verification and any log audit work again. Three badly ordered lines can leave a hole open for months without anyone noticing.
After wrapping up this original series, I added one more post about the VPS admin panel, with Cloudflare Tunnel and Access to take Dokploy, Infisical and Umami out of public DNS. Same approach, one more layer of defense before the first byte is served.

Jose, author of the blog
QA Engineer. I write out loud about automation, AI and software architecture. If something here helped you, write to me and tell me about it.
Leave the first comment
What did you think? What would you add? Every comment sharpens the next post.
If you liked this

ScamDetector, un detector de estafas con inteligencia artificial
ScamDetector combina inteligencia artificial, búsqueda de reputación de teléfonos y escaneo de URLs para ayudarte a identificar estafas digitales. Sin registro, sin datos almacenados.

Guía práctica de hardening para tu VPS Linux: de CrowdSec al kernel
Repaso completo de las medidas de seguridad que puedes aplicar a un VPS Linux: desde CrowdSec y el firewall hasta el hardening del kernel, pasando por SSH, Docker y las actualizaciones automáticas.

Cómo verificamos que nadie manipula los posts de este blog
Nuestros posts viven en una base de datos SQLite. Si alguien accede a ella, puede cambiar cualquier artículo sin dejar rastro. Construimos un verificador externo con hashes SHA-256 y firma Ed25519 que vigila la integridad desde un segundo servidor.