DEV Community

Zach S. for Google Cloud

Posted on

Verifying Google Cloud egress IP ranges with cloud.json and jq

When GCE workloads or Cloud NAT gateways connect to external partner endpoints, it is common to run into asymmetric reachability issues - connections from one region work normally, while connections from another region (or from a newly reserved NAT IP in the same region) time out on the initial TCP SYN. In most cases, this happens because the destination firewall uses a static IP allowlist that hasn't been updated, or relies on a stale GeoIP database (for example, many regional prefixes in asia-* and europe-* are carved out of Google's ARIN 34.0.0.0/8 block, which still shows Country: US in raw WHOIS if the firewall doesn't ingest Google's IP feeds). To verify how Google advertises your egress IPs externally, you can cross-reference your project's reserved addresses from the Method: addresses.list | Compute Engine API against cloud.json.

Google publishes cloud.json as the machine-readable feed of all customer-usable external IPv4 and IPv6 ranges by region, as documented in Configure Private Google Access | Virtual Private Cloud - Google Cloud Documentation. When you are setting up a new Cloud NAT gateway or troubleshooting an outbound block with a partner network team, you can use this feed to quickly confirm which published CIDR block covers each of your external IPs and when the feed was last updated.

Automating the check with gcloud and jq

To avoid manually searching through more than a thousand prefixes in cloud.json, you can script this check. The script below uses gcloud compute addresses list | Google Cloud SDK to grab your reserved external IPs as JSON, fetches cloud.json, and uses jq to match each IPv4 address to its published CIDR range and regional scope:

#!/bin/bash

# --- Configuration (Replace with your details) ---
PROJECT="<YOUR_PROJECT_ID>"
REGION="<YOUR_REGION>"
CLOUD_IPS_URL="https://www.gstatic.com/ipranges/cloud.json"

echo "Checking external IPs in $PROJECT ($REGION) against $CLOUD_IPS_URL..."
echo "------------------------------------------------------------------------"

# Fetch reserved external IPs and match each IPv4 address against cloud.json CIDRs in jq
jq -rn \
  --argjson addrs "$(gcloud compute addresses list \
    --project="$PROJECT" \
    --regions="$REGION" \
    --filter="addressType=EXTERNAL" \
    --format=json)" \
  --argjson cloud "$(curl -s "$CLOUD_IPS_URL")" '
  # Convert an IPv4 address string into a 32-bit integer for CIDR matching
  def ip2int:
    split(".") | map(tonumber) | (.[0] * 16777216) + (.[1] * 65536) + (.[2] * 256) + .[3];

  # Regional IPv4 addresses omit ipVersion in the API response, while IPv6 reservations set "IPV6"
  $addrs[] | select((.ipVersion // "IPV4") == "IPV4") as $a |
  ($a.address | ip2int) as $ip_int |
  (
    [
      $cloud.prefixes[]
      | select(has("ipv4Prefix"))
      | (.ipv4Prefix | split("/")) as $parts
      | ($parts[0] | ip2int) as $net_int
      | ($parts[1] | tonumber) as $mask
      | (pow(2; 32 - $mask)) as $size
      | select($ip_int >= $net_int and $ip_int < ($net_int + $size))
    ][0] // { ipv4Prefix: "UNLISTED", scope: "UNKNOWN" }
  ) as $match |
  "Name: \($a.name) | IP: \($a.address) | Tier: \($a.networkTier) | Prefix: \($match.ipv4Prefix) | Scope: \($match.scope)"
'
Enter fullscreen mode Exit fullscreen mode

Let's list the reserved external IP addresses in our target region first:

~  gcloud compute addresses list \
    --project="my-gcp-project" \
    --regions="us-south1" \
    --format='table(name, address, region.basename(), networkTier, status)'

NAME       ADDRESS         REGION     NETWORK_TIER  STATUS
nat-ip-a0  34.174.136.135  us-south1  PREMIUM       RESERVED
nat-ip-a1  34.174.242.159  us-south1  PREMIUM       RESERVED
nat-ip-a2  34.0.132.72     us-south1  STANDARD      RESERVED
Enter fullscreen mode Exit fullscreen mode

Running the script maps each external IP to its parent CIDR block and regional scope in cloud.json:

~  bash gce-egress-prefix-check.sh

Checking external IPs in my-gcp-project (us-south1) against https://www.gstatic.com/ipranges/cloud.json...
------------------------------------------------------------------------
Name: nat-ip-a0 | IP: 34.174.136.135 | Tier: PREMIUM | Prefix: 34.174.0.0/16 | Scope: us-south1
Name: nat-ip-a1 | IP: 34.174.242.159 | Tier: PREMIUM | Prefix: 34.174.0.0/16 | Scope: us-south1
Name: nat-ip-a2 | IP: 34.0.132.72 | Tier: STANDARD | Prefix: 34.0.128.0/19 | Scope: us-south1
Enter fullscreen mode Exit fullscreen mode

Under the hood: Reading the cloud.json payload

When you fetch cloud.json, the response includes a top-level syncToken and creationTime, followed by a prefixes array. For IPv4 ranges in us-south1, the JSON payload looks like this:

{
  "syncToken": "1791792540784",
  "creationTime": "2026-10-12T01:09:00.784842",
  "prefixes": [
    {
      "ipv4Prefix": "34.0.128.0/19",
      "service": "Google Cloud",
      "scope": "us-south1"
    },
    {
      "ipv4Prefix": "34.174.0.0/16",
      "service": "Google Cloud",
      "scope": "us-south1"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Notice that nat-ip-a0 (PREMIUM tier) and nat-ip-a2 (STANDARD tier) come from different CIDR blocks (34.174.0.0/16 vs. 34.0.128.0/19) within us-south1. Compute Engine tracks the network service tier on each address resource (networkTier), while cloud.json groups both Premium and Standard tier external ranges under the same regional scope, as described in IP addresses | Compute Engine - Google Cloud Documentation:

{
  "address": "34.0.132.72",
  "addressType": "EXTERNAL",
  "kind": "compute#address",
  "name": "nat-ip-a2",
  "networkTier": "STANDARD",
  "region": "https://www.googleapis.com/compute/v1/projects/my-gcp-project/regions/us-south1",
  "status": "RESERVED"
}
Enter fullscreen mode Exit fullscreen mode

If you are using external IPv6 ranges on dual-stack subnets, cloud.json also includes the regional /44 IPv6 blocks under the ipv6Prefix field:

{
  "ipv6Prefix": "2600:1901:8140::/44",
  "service": "Google Cloud",
  "scope": "us-south1"
}
Enter fullscreen mode Exit fullscreen mode

A few quick constraints

  • API Support: Listing static external IPs works with both regional Method: addresses.list | Compute Engine and global/cross-region Method: addresses.aggregatedList | Compute Engine.
  • gcloud Support: Filter out internal RFC 1918 reservations by passing --filter="addressType=EXTERNAL" to gcloud compute addresses list | Google Cloud SDK. If your VMs use ephemeral external IPs instead of reserved addresses, pull .networkInterfaces[].accessConfigs[].natIP from gcloud compute instances list | Google Cloud SDK.
  • cloud.json vs. goog.json: Google publishes two separate IP range files. cloud.json lists customer-usable Google Cloud ranges with regional scope values, while goog.json covers all Google-owned prefixes without regional scopes.
  • IPv4 vs. IPv6 Keys: In addresses.list, the Compute Engine API populates "ipVersion": "IPV6" on IPv6 reservations but omits ipVersion on regional IPv4 addresses, so filter with select((.ipVersion // "IPV4") == "IPV4") before passing .address to ip2int. Similarly, each entry in cloud.json .prefixes[] uses either ipv4Prefix or ipv6Prefix, so guard IPv4 prefix splitting with select(has("ipv4Prefix")).

Next steps

Next time an external endpoint drops traffic from a new region or Cloud NAT gateway, run this script to confirm the exact ipv4Prefix and creationTime in cloud.json. If the destination firewall hasn't picked up the prefix yet, sharing the specific regional block (or your reserved /32 NAT IPs) gives the remote network team what they need to update their allowlist.

Top comments (6)

Collapse
 
mona_d_4222dda374567263b profile image
Mona D. •

Nice write-up. Doing the CIDR match entirely in jq with the ip2int helper is a neat approach, since it avoids pulling in Python or ipcalc for something you'd want to run on any jump box or Cloud Shell session.

The detail that the Premium and Standard tier addresses land in different blocks (34.174.0.0/16 vs. 34.0.128.0/19) but share the same regional scope in cloud.json was new to me, and it's a useful thing to know before telling a partner team which ranges to allowlist. I also appreciated the note about stale GeoIP databases showing Country: US for regional prefixes in 34.0.0.0/8, which is the kind of root cause that eats hours when a firewall silently drops the SYN.

One small suggestion: it might be worth adding a short example of the aggregatedList variant for people with NAT IPs spread across multiple regions, or a line on how to diff the creationTime between runs so you can tell whether a feed update lines up with when the connectivity problems began. Thanks for sharing this.

Collapse
 
zachs profile image
Zach S. Google Cloud •

Thanks, glad it was useful.

For multi-region checks, omitting --regions="$REGION" from gcloud compute addresses list automatically calls addresses.aggregatedList under the hood and flattens every regional and global address into the same top-level JSON array. You can pull the region name from $a.region in jq (or if you're calling the raw REST addresses.aggregatedList endpoint directly, unwrap .items with [.items[]?.addresses[]?]):

$addrs[] | select((.ipVersion // "IPV4") == "IPV4") as $a |
(($a.region // "global") | split("/")[-1]) as $region |
Enter fullscreen mode Exit fullscreen mode

To check whether a cloud.json update lines up with when a connectivity issue started, save a local copy of the feed (for example, cloud.prev.json) and diff creationTime and the prefix list between runs using jq array subtraction:

jq -rn \
  --slurpfile old cloud.prev.json \
  --argjson new "$(curl -s https://www.gstatic.com/ipranges/cloud.json)" '
  ($old[0]) as $o |
  if $o.syncToken == $new.syncToken then
    "No change (creationTime: $$new.creationTime))"
  else
    "Updated: $$o.creationTime) -> $$new.creationTime) (syncToken: $$new.syncToken))",
    (
      (
        [$new.prefixes[] | select(has("ipv4Prefix")) | "$.ipv4Prefix) ($.scope))"]
        - [$o.prefixes[] | select(has("ipv4Prefix")) | "$.ipv4Prefix) ($.scope))"]
      )[] | "  + $.)"
    )
  end
'
Enter fullscreen mode Exit fullscreen mode
Collapse
 
wrobeltomasz profile image
Tomasz •

How can you automate IP range updates when Google changes egress IPs?

Collapse
 
zachs profile image
Zach S. Google Cloud •

There are two ways to handle this depending on whether you control the egress path:

  1. Use static Cloud NAT IPs when possible: If your workloads run on GCE, GKE, or Cloud Run (with Direct VPC egress), route outbound traffic through Cloud NAT with manually reserved static regional external IPs. That pins your egress traffic to specific /32 addresses so you don't have to track regional prefix changes in cloud.json.
  2. Poll syncToken in cloud.json for dynamic ranges: If you need to allowlist regional Google Cloud ranges dynamically on a firewall or WAF, poll https://www.gstatic.com/ipranges/cloud.json on a schedule (for example, once a day via a cron job or Cloud Scheduler) and compare the top-level syncToken against the last value you processed. When syncToken changes, extract the current prefixes for your target region(s) and push them to your firewall's address group API:
CLOUD_JSON=$(curl -s "https://www.gstatic.com/ipranges/cloud.json")
NEW_TOKEN=$(echo "$CLOUD_JSON" | jq -r '.syncToken')
LAST_TOKEN=$(cat /tmp/cloud_sync_token 2>/dev/null || echo "")

if [ "$NEW_TOKEN" != "$LAST_TOKEN" ]; then
  echo "$CLOUD_JSON" | jq -r '.prefixes[] | select(.scope == "us-south1" and has("ipv4Prefix")) | .ipv4Prefix'
  echo "$NEW_TOKEN" > /tmp/cloud_sync_token
fi
Enter fullscreen mode Exit fullscreen mode
Collapse
 
m_montazeri profile image
Mohammad Montazeri •

One small dual-stack edge case in the script: select(has("ipv4Prefix")) filters the cloud.json entries, but $a.address reaches ip2int before that filter. An external IPv6 reservation in the address list would therefore reach the IPv4-only split(".") | map(tonumber) path.

For this IPv4 example, filter the address records to ipVersion == "IPV4" before calling ip2int, or explicitly handle IPv6 separately. The Compute API documents ipVersion as IPV4 or IPV6: docs.cloud.google.com/compute/docs...

A mixed IPv4/IPv6 fixture would be a useful check for that case. This is a code-reading observation; I have not run it against your project.

AI-assisted technical feedback; I work on Lisar Connect.

Collapse
 
zachs profile image
Zach S. Google Cloud • • Edited

Thanks for the feedback. If you have regional external IPv6 reservations in the same region, $addrs[] will pass those IPv6 addresses into ip2int and fail on split(".") | map(tonumber).

One quirk with the Compute Engine API when filtering on ipVersion: while ipVersion is documented in the schema, addresses.list only populates "ipVersion": "IPV6" on IPv6 reservations and omits the ipVersion field entirely on regional IPv4 compute#address objects (as shown in the nat-ip-a2 JSON snippet above). Because .ipVersion evaluates to null for regional IPv4 addresses, filtering strictly on select(.ipVersion == "IPV4") (or --filter="ipVersion=IPV4" in gcloud) returns an empty result.

To handle mixed IPv4/IPv6 reservations safely in jq, default missing ipVersion values to "IPV4" before calling ip2int:

$addrs[] | select((.ipVersion // "IPV4") == "IPV4") as $a |