Before excluding an IP from Google Ads, establish which system supplied it. Behind Cloudflare, a CDN or a load balancer, an origin connection can come from a proxy. A header can carry an earlier address, but accepting a header from any sender lets the sender choose your evidence.

This diagnostic includes a local JSONL reviewer and a ten-record exercise. It keeps the transport peer, the address selected under your configured trust rules and unresolved observations separate. It makes no network request, changes no account and writes no report file unless you redirect its output yourself.

1. Which address did the logger record?

Draw the actual path: browser → CDN → load balancer → origin. At each layer, record the connection peer and any address rewriting. A field called remote_addr is not enough context: it might already have been replaced by real-IP middleware. Do not relabel a rewritten field as the original connection peer.

  • One repeated proxy address: may group many visitors under an infrastructure hop. It does not show that one person clicked repeatedly.
  • One forwarded address: needs a known sender, header policy and path. A plausible public address is not authentication.
  • Several logging layers: can record the same request more than once. Correlate request IDs instead of summing edge and origin rows.

For nginx, the real-IP module uses set_real_ip_from to define trusted replacement-address sources and real_ip_header to select the field. $realip_remote_addr retains the original client address before replacement. Module availability and your installed configuration matter; this guide does not alter or validate your nginx server.

nginx real-IP module and original-address variables, checked 3 November 2026.

2. CF-Connecting-IP and X-Forwarded-For are different inputs

Cloudflare supplies CF-Connecting-IP on edge-to-origin requests. Its documentation recommends that single-address field over parsing XFF for the usual restoration case. Verify that the request actually came through the expected path, use the current provider prefixes and account for any intermediate load balancer. A range match alone does not authenticate your own Cloudflare zone.

Check Workers, header transforms and Pseudo IPv4 mode before calling the result an original visitor address. Worker subrequests can alter header semantics. Pseudo IPv4 in Overwrite Headers mode substitutes a pseudo IPv4 value and preserves the original IPv6 in CF-Connecting-IPv6. This reviewer does not detect those settings or automatically switch headers.

Cloudflare visitor-IP restoration; Cloudflare header, Worker and Pseudo IPv4 behavior; current published Cloudflare IP ranges.

For a verified proxy chain using XFF, start with the actual peer. If it is not trusted, ignore the forwarded value. If it is trusted, work from the right and stop at the first address outside the configured proxy ranges. Do not jump past that hop to an attractive leftmost address. The selected hop can still be an intermediate proxy rather than an end user.

MDN: XFF trust boundaries and right-to-left selection. A header is not a universal person identifier.

When the resolved address belongs to a privacy network, use the Private Relay range and outcome review before assigning it to an individual or an exclusion.

3. Review a local export before preparing exclusions

Download the Python export reviewer and ten-record JSONL example into one folder. Use Python 3.10 or later; no packages are required. Run:

python3 trusted-proxy-log-review.py proxy-log-example.jsonl \
  --header x-forwarded-for \
  --trusted-proxy 192.0.2.0/24 \
  --trusted-proxy 2001:db8:100::/48

The sample CIDRs are documentation ranges

They are invented proxy roles for this exercise, not Cloudflare’s current networks and not exclusion candidates. For your own export, use verified ranges for the proxies actually supplying your selected header. Never copy the sample trust list into a server or ad account.

Each input line is one JSON object with peer_ip, a headers object, method and request_target. Export the peer before rewriting, preserve header duplicates and select the observation period yourself. The program accepts a local regular file up to 10 MiB; it does not fetch URLs, select dates or discover your topology.

Choose --header cf-connecting-ip only for an export whose path and single-value header semantics you have verified. The reviewer does not fall back to XFF when that field is absent. Header names are matched without case; IP literals cannot contain ports or zone IDs. IPv4-mapped IPv6 peers are normalized to IPv4 and need IPv4 trust CIDRs.

Duplicate literal JSON keys or malformed JSON stop the command before a report is printed. Duplicate header names with different case, array values, malformed addresses and missing headers remain unresolved. This conservative format does not reproduce every web framework’s header parser; export repeated headers without silently choosing one.

Inspect the complete reviewer
#!/usr/bin/env python3
"""Review a local JSONL export. No network access, DNS, account calls or writes.

This is an offline evidence reviewer, not middleware or a production trust policy.
Supply the actual transport peer before any address rewriting, not a header value.
"""
import argparse
from collections import Counter
import ipaddress
import json
from pathlib import Path
import sys

MAX_BYTES = 10 * 1024 * 1024
MAX_HEADER = 4096
MAX_HOPS = 32


def address(value):
    if not isinstance(value, str) or '%' in value:
        raise ValueError('An IP literal without a zone or port is required')
    result = ipaddress.ip_address(value.strip())
    if isinstance(result, ipaddress.IPv6Address) and result.ipv4_mapped:
        result = result.ipv4_mapped
    return result


def trusted(ip, networks):
    return any(ip.version == network.version and ip in network for network in networks)


def review(row, line, networks, header):
    result = {'line': line, 'peer_ip': None, 'client_ip': None,
              'status': 'unresolved', 'reason': ''}
    try:
        if not isinstance(row, dict):
            raise ValueError('Expected an object')
        peer = address(row.get('peer_ip'))
    except ValueError:
        result['reason'] = 'invalid_peer'
        return result
    result['peer_ip'] = str(peer)
    if not trusted(peer, networks):
        result.update(client_ip=str(peer), status='transport_peer',
                      reason='forwarded_header_not_trusted')
        return result
    headers = row.get('headers', {})
    if not isinstance(headers, dict):
        result['reason'] = 'invalid_headers_object'
        return result
    values = [value for key, value in headers.items() if key.casefold() == header]
    if len(values) != 1:
        result['reason'] = 'missing_or_duplicate_header'
        return result
    raw = values[0]
    if not isinstance(raw, str) or not raw or len(raw) > MAX_HEADER:
        result['reason'] = 'invalid_header'
        return result
    parts = raw.split(',')
    if len(parts) > MAX_HOPS or (header == 'cf-connecting-ip' and len(parts) != 1):
        result['reason'] = 'invalid_header'
        return result
    try:
        chain = [address(part) for part in parts]
    except ValueError:
        result['reason'] = 'invalid_header'
        return result
    if header == 'cf-connecting-ip':
        client = chain[0]
    else:
        client = next((hop for hop in reversed(chain) if not trusted(hop, networks)), None)
        if client is None:
            result['reason'] = 'no_untrusted_hop'
            return result
    result.update(client_ip=str(client), status='resolved_header', reason='configured_trust_boundary')
    return result


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError('Duplicate JSON key: ' + key)
        result[key] = value
    return result


def run(args):
    networks = []
    for value in args.trusted_proxy:
        network = ipaddress.ip_network(value, strict=True)
        if network.prefixlen == 0:
            raise ValueError('A trust-all network is not supported')
        if isinstance(network, ipaddress.IPv6Network) and network.subnet_of(ipaddress.ip_network('::ffff:0:0/96')):
            raise ValueError('Use IPv4 CIDRs for IPv4-mapped peers')
        networks.append(network)
    path = Path(args.input)
    if not path.is_file():
        raise ValueError('Input must be a local regular file')
    with path.open('rb') as source:
        raw = source.read(MAX_BYTES + 1)
    if len(raw) > MAX_BYTES:
        raise ValueError('Select an export window of at most 10 MiB')
    records = []
    normalized = []
    for line, text in enumerate(raw.decode('utf-8-sig').splitlines(), 1):
        if not text.strip():
            continue
        try:
            row = json.loads(text, object_pairs_hook=unique_object)
        except ValueError as error:
            raise ValueError(f'Invalid JSON at line {line}: {error}') from error
        result = review(row, line, networks, args.header)
        records.append(result)
        target = row.get('request_target') if isinstance(row, dict) else None
        # Only resolved headers and GET origin-form targets reach builder output.
        # Never substitute a trusted proxy peer for a missing client observation.
        if result['status'] == 'resolved_header' and row.get('method') == 'GET' and isinstance(target, str):
            if target.startswith('/') and not target.startswith('//') and not any(ch.isspace() for ch in target):
                normalized.append(f"{result['client_ip']} GET {target}")
    counts = Counter(record['status'] for record in records)
    return {'header': args.header, 'trusted_proxy_cidrs': [str(n) for n in networks],
            'summary': {'records': len(records), 'resolved_header': counts['resolved_header'],
                        'transport_peer': counts['transport_peer'], 'unresolved': counts['unresolved'],
                        'builder_lines': len(normalized)},
            'records': records, 'builder_lines': normalized}


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('input', help='local JSONL export; not a URL')
    parser.add_argument('--header', choices=['x-forwarded-for', 'cf-connecting-ip'], required=True)
    parser.add_argument('--trusted-proxy', action='append', default=[], help='repeat for each verified transport-proxy CIDR')
    parser.add_argument('--format', choices=['json', 'builder'], default='json')
    args = parser.parse_args(argv)
    try:
        report = run(args)
    except (ValueError, OSError, UnicodeError) as error:
        print(str(error), file=sys.stderr)
        return 2
    if args.format == 'builder':
        print('\n'.join(report['builder_lines']))
        print(json.dumps(report['summary']), file=sys.stderr)
    else:
        print(json.dumps(report, indent=2))
    return 0


if __name__ == '__main__':
    sys.exit(main())
Inspect the ten input records
{"peer_ip":"192.0.2.10","headers":{"X-Forwarded-For":"198.51.100.25"},"method":"GET","request_target":"/quote?gclid=Proxy_Sample_1"}
{"peer_ip":"203.0.113.9","headers":{"X-Forwarded-For":"198.51.100.250"},"method":"GET","request_target":"/quote?gclid=Proxy_Sample_Direct"}
{"peer_ip":"192.0.2.10","headers":{"X-Forwarded-For":"198.51.100.25, 192.0.2.20"},"method":"GET","request_target":"/quote?gclid=Proxy_Sample_2"}
{"peer_ip":"192.0.2.10","headers":{"X-Forwarded-For":"198.51.100.250, 203.0.113.9"},"method":"GET","request_target":"/quote?gclid=Proxy_Sample_3"}
{"peer_ip":"192.0.2.10","headers":{},"method":"GET","request_target":"/quote?gclid=Proxy_Sample_Missing"}
{"peer_ip":"192.0.2.10","headers":{"X-Forwarded-For":"not-an-ip"},"method":"GET","request_target":"/quote?gclid=Proxy_Sample_Invalid"}
{"peer_ip":"192.0.2.10","headers":{"X-Forwarded-For":"192.0.2.20"},"method":"GET","request_target":"/quote?gclid=Proxy_Sample_AllTrusted"}
{"peer_ip":"2001:db8:100::10","headers":{"X-Forwarded-For":"2001:db8:200::25"},"method":"GET","request_target":"/quote?gclid=Proxy_Sample_IPv6"}
{"peer_ip":"192.0.2.10","headers":{"X-Forwarded-For":"198.51.100.25","x-forwarded-for":"203.0.113.9"},"method":"GET","request_target":"/quote?gclid=Proxy_Sample_Duplicate"}
{"peer_ip":"192.0.2.10","headers":{"X-Forwarded-For":"198.51.100.25"},"method":"GET","request_target":"/quote?gclid=Proxy_Sample_4"}

4. Five resolved headers, one transport peer, four unknowns

The checked command produces 10 records: 5 resolved under the supplied trust boundary, 1 untrusted transport peer and 4 unresolved records. Only the five resolved GET records become builder lines. An untrusted peer remains a transport observation in the JSON report; it is deliberately absent from that output.

LineTransport peerSelected addressStatus / reason
1192.0.2.10198.51.100.25resolved_header
configured_trust_boundary
2203.0.113.9203.0.113.9transport_peer
forwarded_header_not_trusted
3192.0.2.10198.51.100.25resolved_header
configured_trust_boundary
4192.0.2.10203.0.113.9resolved_header
configured_trust_boundary
5192.0.2.10unresolvedunresolved
missing_or_duplicate_header
6192.0.2.10unresolvedunresolved
invalid_header
7192.0.2.10unresolvedunresolved
no_untrusted_hop
82001:db8:100::102001:db8:200::25resolved_header
configured_trust_boundary
9192.0.2.10unresolvedunresolved
missing_or_duplicate_header
10192.0.2.10198.51.100.25resolved_header
configured_trust_boundary

Line 2 ignores a supplied address because the sender is untrusted. Line 4 stops at the rightmost untrusted hop rather than accepting the supplied leftmost value. Lines 5–7 and 9 preserve missing, malformed, all-trusted and duplicate-header uncertainty. Line 8 exercises IPv6.

Download the actual reference JSON. Re-run the command and compare the entire result. These records test the reviewer’s selection rules, not Cloudflare delivery, live nginx behavior, fraud detection or Google billing.

Inspect the reference report
{
  "header": "x-forwarded-for",
  "trusted_proxy_cidrs": [
    "192.0.2.0/24",
    "2001:db8:100::/48"
  ],
  "summary": {
    "records": 10,
    "resolved_header": 5,
    "transport_peer": 1,
    "unresolved": 4,
    "builder_lines": 5
  },
  "records": [
    {
      "line": 1,
      "peer_ip": "192.0.2.10",
      "client_ip": "198.51.100.25",
      "status": "resolved_header",
      "reason": "configured_trust_boundary"
    },
    {
      "line": 2,
      "peer_ip": "203.0.113.9",
      "client_ip": "203.0.113.9",
      "status": "transport_peer",
      "reason": "forwarded_header_not_trusted"
    },
    {
      "line": 3,
      "peer_ip": "192.0.2.10",
      "client_ip": "198.51.100.25",
      "status": "resolved_header",
      "reason": "configured_trust_boundary"
    },
    {
      "line": 4,
      "peer_ip": "192.0.2.10",
      "client_ip": "203.0.113.9",
      "status": "resolved_header",
      "reason": "configured_trust_boundary"
    },
    {
      "line": 5,
      "peer_ip": "192.0.2.10",
      "client_ip": null,
      "status": "unresolved",
      "reason": "missing_or_duplicate_header"
    },
    {
      "line": 6,
      "peer_ip": "192.0.2.10",
      "client_ip": null,
      "status": "unresolved",
      "reason": "invalid_header"
    },
    {
      "line": 7,
      "peer_ip": "192.0.2.10",
      "client_ip": null,
      "status": "unresolved",
      "reason": "no_untrusted_hop"
    },
    {
      "line": 8,
      "peer_ip": "2001:db8:100::10",
      "client_ip": "2001:db8:200::25",
      "status": "resolved_header",
      "reason": "configured_trust_boundary"
    },
    {
      "line": 9,
      "peer_ip": "192.0.2.10",
      "client_ip": null,
      "status": "unresolved",
      "reason": "missing_or_duplicate_header"
    },
    {
      "line": 10,
      "peer_ip": "192.0.2.10",
      "client_ip": "198.51.100.25",
      "status": "resolved_header",
      "reason": "configured_trust_boundary"
    }
  ],
  "builder_lines": [
    "198.51.100.25 GET /quote?gclid=Proxy_Sample_1",
    "198.51.100.25 GET /quote?gclid=Proxy_Sample_2",
    "203.0.113.9 GET /quote?gclid=Proxy_Sample_3",
    "2001:db8:200::25 GET /quote?gclid=Proxy_Sample_IPv6",
    "198.51.100.25 GET /quote?gclid=Proxy_Sample_4"
  ]
}

5. Send selected client lines to the local IP review

Repeat the command with --format builder to print only eligible lines to standard output; record counts go to standard error. The formatter preserves the GET request target without decoding its query. It omits non-GET, absolute, scheme-relative or whitespace-containing targets. It does not apply a landing-path, time, campaign or public-address filter.

Download the checked normalized sample. Copy its five lines into the builder below. At the default three-distinct-GCLID threshold, the sample leaves 198.51.100.25 for review: its three distinct invented values appear across lines 1, 3 and 10.

198.51.100.25 GET /quote?gclid=Proxy_Sample_1
198.51.100.25 GET /quote?gclid=Proxy_Sample_2
203.0.113.9 GET /quote?gclid=Proxy_Sample_3
2001:db8:200::25 GET /quote?gclid=Proxy_Sample_IPv6
198.51.100.25 GET /quote?gclid=Proxy_Sample_4

Free tool

Build your IP exclusion list

Paste addresses from a log, a CSV export or a report, in any layout. You get a clean list in the format Google Ads accepts, with repeats removed and the most active addresses first. Paste a server log to count distinct GCLIDs from each address.

addresses share the first three parts

0 entries

Runs in your browser. Nothing you paste is sent anywhere.

For your own data, review resolved lines from one minimal cohort. Do not paste the JSON report itself into a free-form IP extractor: its peer fields are diagnostic evidence and may contain proxy addresses. Range hints and repeated identifiers still do not establish a person or fraudulent intent. The request and click-ID counting guide explains the next reconciliation step.

6. Repair the observation before acting on the traffic

  1. Record the source field, topology, exact header and verified trust list with an owner and update date.
  2. Ask the site operator to validate proxy/real-IP settings and direct-origin access in the actual stack. Preserve existing legitimate paths and a rollback plan.
  3. Compare a controlled request at the edge and origin using their request IDs. Check whether the peer and selected address match the intended boundary.
  4. Re-export the selected period and account for every unresolved record before making an exclusion proposal.
  5. Review legitimate business outcomes, supported exclusion scope and the setting saved by Google Ads. A prepared list has not changed the account.

Download the client-address review worksheet.

Inspect the review worksheet
CLIENT-ADDRESS EVIDENCE REVIEW

Scope: hostname, observation layer, date range, timezone and landing path.

Topology: browser -> CDN -> load balancer -> origin. Record every actual hop.
Transport peer: field captured before any address rewriting, with its source.
Header: exact selected header, duplicates, serialization and any transformations.
Trust: verified proxy CIDRs, owner, reference, update date and direct-origin policy.
Cloudflare: check Workers, header transforms and Pseudo IPv4 mode if applicable.

Export: use peer_ip, headers, method and request_target; one JSON object per line.
Preserve repeated header fields as an array or duplicate case variants for review;
do not silently collapse repeated headers into one chosen value. Array values are
unresolved in this reviewer. Duplicate literal JSON keys are a file error.
Do not relabel an already rewritten remote address as the original transport peer.

Findings: records resolved under supplied trust, peer-only records, unresolved
reasons and lines eligible for the builder. Preserve the original export separately.
The first untrusted forwarded hop can be another proxy rather than an end user.

Action: inspect a minimal cohort before any exclusion. Never block proxy networks
because a missing header left their addresses in an origin log. Check legitimate
outcomes, actual account exclusion scope, saved settings and a rollback condition.

Billing: website request evidence does not establish a new paid click, invalid
charge or credit. Investigate advertising reporting and billing separately.

https://adprotektor.app/blog/client-ip-behind-cloudflare/

A corrected client address is still website evidence. It cannot tell you whether a new paid click happened, whether Google already filtered it or whether a credit is due. Use the invalid-traffic billing guide and the exclusion scope and read-back checklist for those separate questions.

The local reviewer uses Python’s IP address and network parsers. Its offline behavior, input preservation and trust-edge cases are tested; supplied trust still needs independent operational evidence.

Where AdProtektor fits

AdProtektor adds visit evidence and configurable protection. Eligible Google Ads exclusions require an active connection and confirmation that the action reached its destination. Review site-side decisions alongside qualified outcomes and actual billing. Neither a header nor a third-party visitor flag guarantees a charge, a refund or complete protection.

Start the free traffic audit, or use the conversion-action audit when the problem is what your campaign treats as a successful lead.

Already using website security? The Cloudflare and ad-protection buyer guide separates challenge responses, legitimate customer completion, applied ad exclusions and billing evidence.