Back to Blog
Security

Allowlisting Webhook Sender IPs: A Control That Breaks Quietly

Locking your webhook endpoint to a provider's IP ranges feels like security, but the list changes without warning, retired ranges get reassigned to strangers, and a proxy in front of you hides the real source. Here is what IP allowlisting actually buys you and how it fails.

WebhookVault Team · Webhook Infrastructure Experts13 min read
Close-up of a network patch panel with numbered RJ45 ports labelled 072 through 091, several grey and blue Ethernet cables plugged into the sockets

A firewall rule quietly ate three days of webhooks

Someone added an allow rule to lock your webhook endpoint down to the sender's published IP ranges. Clean idea. Only verified traffic from the provider gets through, everything else hits a closed port. It worked in the demo, it passed review, and for eight months nothing went wrong.

Then the provider rotated a subnet. Half their delivery fleet moved to addresses that were not in your rule. Your firewall did exactly what you told it to and dropped those connections at the network layer, before your app ever saw a byte. No 500, no log line in your handler, no failed signature to alert on. From your side the traffic simply stopped existing. You found out three days later when a customer asked why their orders never synced.

That is the whole problem with IP allowlisting for webhooks in one incident. It is a control that fails silent and fails closed, and the failure looks nothing like an error. Before you reach for it, get clear on what it actually does, because it is not the thing most people think it is.

Allowlisting is a network filter, not authentication

An IP allowlist answers exactly one question: did this packet come from an address on my list. That is it. It does not prove the request is genuinely from the provider, because source IPs on the public internet are a routing hint, not an identity. It says nothing about whether the payload is intact or whether the event is a replay. It is a coarse filter that runs before your application logic, and coarse filters are worth having as long as you are honest about the sentence they can finish.

The danger is the false sense of done. A team locks the endpoint to the provider's ranges, watches unauthorized traffic drop to zero, and quietly decides signature checking is now optional. It is not. Anything sharing that cloud region can originate traffic from an allowlisted address, and a misconfigured proxy in the path can launder a request through one. The signature verification is the control that proves the payload came from the sender and arrived unmodified. IP allowlisting is a fence around the yard. It keeps the casual noise out and says nothing about whether the person at your door is who they claim to be.

So the rule is simple. Allowlisting is additive. It goes on top of HMAC verification, never instead of it. If dropping the allowlist would leave your endpoint insecure, your endpoint was already insecure.

What the providers actually give you

Assuming you still want the fence, the first thing you need is a list of addresses, and here the providers diverge sharply. Stripe publishes a short, explicit set: a page listing roughly fifteen IPv4 addresses that webhook notifications originate from, plus machine-readable feeds at ips_webhooks.txt and ips_webhooks.json you can pull into tooling. Crucially, Stripe commits to seven days of notice through its API announce mailing list before changing those addresses. That is generous. Most providers give you nothing like it.

GitHub takes a different but also workable approach. It exposes a metadata endpoint that returns current ranges by function, so you can read the hooks field to get the CIDR blocks GitHub delivers webhooks from, alongside the ranges for its API and web traffic.

# GitHub publishes its webhook source ranges in the meta endpoint
node -e "fetch('https://api.github.com/meta').then(r=>r.json()).then(m=>console.log(m.hooks.join('\n')))"

The shape of the answer matters. Stripe hands you a flat list of individual addresses. GitHub hands you CIDR blocks that can cover thousands of addresses each. An allowlist built from CIDRs is more future-proof, because the provider can move machines within a block without your rule noticing, but it is also a much larger surface you are choosing to trust. Read what you are actually allowing before you paste it into a firewall.

Some providers refuse to give you a list at all

Then there is Shopify, which is refreshingly blunt: it does not publish a webhook IP range, and it tells you not to build one. The reasoning is sound. Shopify's delivery fleet is dynamic enough that any list would be wrong within weeks, so pinning your firewall to yesterday's addresses would cause exactly the silent-drop failure this article opened with. Their guidance is to verify the HMAC on the payload and leave the network open.

This is worth sitting with, because it inverts the instinct. A provider is telling you that adding a network control would make your integration less reliable, not more secure, and they are right. If the list of valid senders genuinely cannot be pinned down, an allowlist is not defense in depth. It is a scheduled outage waiting for the next infrastructure change on their side. When a provider declines to publish ranges, take the hint and lean entirely on the signature.

Never hardcode the list

If you do allowlist, the cardinal sin is baking the addresses into a config file, a Terraform variable, or a security group by hand. Static copies of a moving list are wrong the moment the provider changes anything, and you will not be notified in a way your infrastructure can act on. Even Stripe's seven-day email lands in a human's inbox, not your firewall's config.

The reliable pattern is to fetch the current ranges on a schedule, diff them against what you have, and apply only the delta, atomically. Pull the feed, compare, add what is new, and stage what has disappeared for removal rather than deleting it the instant it vanishes.

import json, urllib.request

def current_ranges(url):
    with urllib.request.urlopen(url) as r:
        return set(json.load(r)["WEBHOOKS"])

published = current_ranges("https://stripe.com/files/ips/ips_webhooks.json")
active = load_active_allowlist()          # what your firewall enforces now

to_add    = published - active            # apply immediately, senders may already use these
to_remove = active - published            # do NOT delete yet, see below

if to_add:
    apply_allowlist(active | to_add)
    log("added", sorted(to_add))

Run that on a short interval as a scheduled job and the common failure mode disappears: when the provider adds addresses, you allow them before deliveries start arriving from them, instead of after a customer complains. The order matters. Add fast, remove slow. New ranges can carry live traffic the instant they appear, so you want them allowed early; retired ranges are the opposite problem, and rushing their removal is its own mistake.

The allowlist that grows forever

Here is the failure nobody plans for. Adding ranges when the provider expands is easy and everyone does it. Removing ranges when the provider contracts is the step that gets skipped, because removing a rule feels risky and nothing breaks when you leave it. So the allowlist only ever grows. Six months in, it holds addresses the provider stopped using long ago, and you have no record of which entries are still live.

An ever-growing allowlist is not a harmless bit of cruft. Every stale entry is an address you are still telling your firewall to trust for unauthenticated network access to your webhook endpoint. The list was supposed to shrink your attack surface, and instead it is quietly expanding it, one un-removed rule at a time. This is why the sync job has to handle removals, not just additions, and why you stage them: you want to retire old ranges deliberately, after a grace period, with a log of what you dropped, rather than never retiring them at all.

Reassigned CIDRs turn your allowlist into a backdoor

The stale-entry problem gets genuinely dangerous when you remember what happens to public IP space. When a provider releases a range, that range does not evaporate. It goes back into the pool and gets reassigned, sometimes within weeks, to an entirely different organization. Cloud providers recycle addresses between customers constantly.

Play that forward. Your allowlist still contains a block the provider abandoned four months ago. That block now belongs to someone else's cloud account, which could be anyone, including an attacker who grabbed the free range precisely because stale allowlists still trust it. You are granting network access to a stranger's infrastructure under a rule you added to improve security. If your signature verification is solid they still cannot forge a valid payload, which is the whole reason allowlisting must never be your only control. But you handed them a foothold you did not mean to, and you did it by treating the allowlist as append-only. The only defense is disciplined removal, the exact discipline an unmanaged list lacks.

The proxy in front of you sees a different IP

Even with a perfectly synced list, allowlisting quietly breaks when there is anything between the internet and your handler, which for most production setups there is. Put a load balancer, a CDN, or a reverse proxy in front of your endpoint and the source address your application observes is the proxy's, not the sender's. Allowlist against the connection's remote address in that setup and you are checking the wrong thing entirely, either rejecting everything or, worse, allowing everything because your own proxy is on the list.

The real client IP is in a forwarded header, typically X-Forwarded-For, and that header is trivially spoofable unless you handle it correctly. Only trust it when the connection reaching your app came from a proxy you actually control, and read the specific position in the chain your infrastructure appends, not the leftmost value a client can set to anything.

// X-Forwarded-For is client-controlled UNLESS the hop is one you trust.
// Trust only the address your own proxy appended, never the leftmost.
function senderIp(req, trustedProxies) {
  const remote = req.socket.remoteAddress
  if (!trustedProxies.has(remote)) return remote  // direct connection, use it
  const chain = (req.headers['x-forwarded-for'] || '').split(',').map(s => s.trim())
  return chain[chain.length - 1] || remote        // the hop your proxy recorded
}

Get this wrong and your allowlist is theater: it appears to enforce something while actually keying off a value the caller controls. This is the same class of mistake that makes SSRF protections fail on the sender side, where trusting a supplied value about network identity is exactly the hole. Whichever direction the traffic flows, an address you did not independently verify is not evidence of anything.

Where allowlisting actually earns its keep

None of this means never allowlist. It means allowlist where the fence pays for its upkeep. The strongest case is a private or internal endpoint that has no business being reachable from the open internet at all, where the allowlist enforces a network boundary that would otherwise not exist. A staging environment that receives real provider traffic is another good fit, since keeping the wider internet out of a non-production system is worth a little maintenance.

Compliance is a legitimate driver too. Some frameworks and some customers require documented network-level access controls, and an automated, audited allowlist satisfies that in a way a signature check alone does not. There is also a modest operational win: filtering unauthenticated junk at the edge keeps random scanner traffic and opportunistic replay attempts off your handler, which trims log noise and load. Just size the effort to the payoff. If you are running the sync job, handling removals, and coping with forwarded headers, make sure you are buying one of these benefits and not just the comforting feeling of a rule.

Treat a dropped delivery as an allowlist symptom

The last thing to internalize is how this control fails, so you recognize it. Because allowlisting drops traffic at the network layer, its failures never show up where you look for webhook problems. There is no rejected request in your access log and no non-2xx response, because the connection never completed a handshake with your app. The webhook just quietly does not arrive, which looks identical to the provider not sending it. That is the hardest failure to diagnose from your side.

That means your monitoring has to watch for absence, not just errors. A sudden drop in delivery volume from a specific provider, especially right after they announce infrastructure changes, should point straight at your allowlist before anything else. Sustained silence is also exactly the condition that leads a sender to disable your endpoint entirely, turning a firewall misconfiguration into a switched-off integration. When webhooks vanish and everything on the application side looks healthy, check what your network is dropping. An allowlist that fails closed will not tell you it did.

Frequently asked questions

Can I use IP allowlisting instead of verifying webhook signatures? No, and treating it as a substitute is the most dangerous mistake here. An IP allowlist only confirms a packet came from an address on your list, which is a routing detail, not proof of who sent it or that the payload is intact. Source addresses can be shared across a provider's other tenants, laundered through a misconfigured proxy, or reassigned after the provider releases them. Signature verification is what proves authenticity and integrity, so allowlisting is only ever a second layer on top of it. If removing the allowlist would leave your endpoint exposed, it was never actually secured.

Why did my webhooks stop arriving with no errors in my logs after months of working? Because an IP allowlist drops traffic at the network layer before your application processes it, so a provider rotating or adding delivery addresses produces silent, log-free failures. The connection never completes, so there is no rejected request and no non-2xx status to alert on. From your side it is indistinguishable from the provider simply not sending. If deliveries dry up while your handler looks perfectly healthy, suspect a stale firewall rule first, especially right after the provider changed infrastructure, and compare your active allowlist against their currently published ranges.

How often do webhook provider IP ranges actually change? Often enough that a hand-maintained list will eventually be wrong, and unpredictably enough that you cannot schedule around it. Stripe commits to seven days of notice through its API announce mailing list before changing addresses, which is unusually generous. Many providers give no notice at all, and Shopify declines to publish a webhook range in the first place because its fleet is too dynamic to pin down. The only safe assumption is that the list is a moving target, which is why you fetch it on a schedule and diff it rather than copying it once into a config file.

Is it safe to leave old IP ranges in my allowlist just to be cautious? No, leaving retired ranges in place is the opposite of cautious. Public IP space gets recycled, so an address a provider releases can be reassigned to a different organization within weeks, and a stale entry then grants unauthenticated network access to whoever holds that range now. An append-only allowlist steadily expands your attack surface instead of shrinking it. Handle removals as deliberately as additions: stage retired ranges, remove them after a short grace period, and keep a log of what you dropped so the list reflects only addresses the provider currently uses.

Do I need to worry about allowlisting if my endpoint sits behind a load balancer or CDN? Yes, because the source address your application sees will be the proxy's, not the sender's, so allowlisting against the raw connection address checks the wrong thing. The real client IP lives in a forwarded header such as X-Forwarded-For, and that header is spoofable unless you only trust it from proxies you control and read the specific position your own infrastructure appended. Get this wrong and the allowlist either rejects legitimate traffic or silently trusts a value the caller can set freely, which defeats the entire point of having it.

Related posts