IP allowlisting and egress controls for webhooks
A consumer’s security team has reviewed your integration and come back with one request: they want to lock their webhook receiver down so it only accepts connections from your sender, and they need your IP ranges to do it. It is a reasonable ask that becomes an operational trap the moment you answer it casually — every address you name becomes a dependency you cannot change without coordinating with every consumer who wrote it into a firewall. This page sits under webhook endpoint hardening and picks up the egress path where preventing SSRF in outbound webhook delivery leaves off: that page cares about where you are allowed to connect, this one cares about what address you appear to come from.
The engineering work splits cleanly. Making the source address stable is an infrastructure problem solved by NAT gateways and route tables. Making it changeable is a product problem solved by a published document, a versioning discipline, and a rollout window. Most teams do the first and skip the second, then discover during an incident that they cannot add capacity in a new region without breaking a bank’s firewall.
Prerequisites
- Cloud networking: dispatch workers in private subnets with route tables you control, and the ability to attach static addresses (AWS Elastic IPs, GCP static external addresses, or equivalent) to NAT gateways.
- A public documentation host that can serve a JSON file with an
ETag, ideally on a hostname that is not itself behind the range you are publishing. - Runtime: Node.js 20+ with TypeScript 5, Express 4 for the ranges endpoint,
undici6 for the self-check. - Signature verification already in place — everything here is a supplement to HMAC signature verification, never a substitute for it.
- Delivery metrics labelled by source address, so you can prove an old range has stopped sending before you retire it.
Step 1: Pin dispatch egress to fixed NAT addresses
The default state of a cloud dispatch fleet is that every worker gets an ephemeral public address, or shares a managed egress pool with every other tenant of that service. Neither is publishable. The fix is a route table that sends all worker traffic to a NAT gateway per availability zone, each holding a static address, with those addresses allocated from a contiguous block so you can publish one CIDR rather than a list of individual hosts.
Reserve more addresses than you need. A /29 gives you eight addresses where you currently use three, and the spare capacity is what lets you add a zone later without a rollout. Allocating exactly three and then needing a fourth is the single most common reason teams end up asking consumers to change firewall rules.
Once the topology is in place, verify it from inside the application rather than trusting the diagram. A startup self-check that observes the real source address and compares it to the published range catches a misconfigured route table immediately, instead of when a consumer’s firewall starts dropping deliveries.
import { request } from 'undici';
export function ipToInt(ip: string): number {
return ip.split('.').reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0;
}
export function inCidr(ip: string, cidr: string): boolean {
const [base, bitsRaw] = cidr.split('/');
const bits = Number(bitsRaw);
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
return (ipToInt(ip) & mask) === (ipToInt(base) & mask);
}
// Run at worker start-up and on a slow interval; a drift here is a page.
export async function assertEgressAddress(expected: string[]): Promise<string> {
const res = await request('https://echo.internal.example/source-address', {
method: 'GET',
headersTimeout: 3_000,
});
const { address } = (await res.body.json()) as { address: string };
const matched = expected.some((cidr) => inCidr(address, cidr));
if (!matched) {
// Do not silently continue: this worker is invisible to consumer firewalls.
throw new Error(`egress_outside_published_range:${address}`);
}
return address;
}
Step 2: Publish the range as a versioned document
A range published only in a PDF or a support-ticket reply cannot be automated against, so consumers hard-code it and never revisit it. Publish a JSON document instead, at a stable URL, with three properties that make automation possible: a monotonically increasing version, a per-CIDR status, and an activation date for entries that are not live yet.
The planned status is the mechanism that makes rotation survivable. It tells a consumer “allow this now; we will start using it on this date”, which converts a coordinated cutover into an ordinary configuration update they can apply on their own schedule.
| Field | Type | Purpose |
|---|---|---|
version |
integer | Increments on every change; consumers compare it against their last-applied value instead of diffing CIDRs |
updated_at |
RFC 3339 timestamp | Human-readable change marker and a sanity check that the document is being maintained |
ranges[].cidr |
CIDR string | The block itself, always expressed as a network prefix rather than a host list |
ranges[].status |
active | planned | retiring |
Tells the consumer whether traffic comes from here today, will later, or is being drained |
ranges[].active_from |
date | The earliest date traffic may originate from a planned block, so the rule can be applied ahead of time |
ranges[].retires_on |
date or null | The date after which a retiring block may be removed from the firewall |
import express from 'express';
import crypto from 'node:crypto';
type RangeStatus = 'active' | 'planned' | 'retiring';
interface EgressRange {
cidr: string;
status: RangeStatus;
active_from: string;
retires_on: string | null;
}
interface EgressDocument {
version: number;
updated_at: string;
ranges: EgressRange[];
}
// Source of truth: generated from infrastructure state, never hand-edited.
const DOCUMENT: EgressDocument = {
version: 7,
updated_at: '2026-07-25T09:00:00Z',
ranges: [
{ cidr: '203.0.113.0/29', status: 'active', active_from: '2026-01-04', retires_on: null },
{ cidr: '198.51.100.16/29', status: 'planned', active_from: '2026-09-01', retires_on: null },
],
};
const body = JSON.stringify(DOCUMENT, null, 2);
const etag = `"${crypto.createHash('sha256').update(body).digest('hex').slice(0, 16)}"`;
export const app = express();
app.get('/.well-known/webhook-egress-ranges.json', (req, res) => {
// A short TTL keeps a planned range from being discovered too late, while the
// ETag means a consumer polling hourly costs almost nothing.
res.set('Cache-Control', 'public, max-age=300');
res.set('ETag', etag);
if (req.headers['if-none-match'] === etag) return res.status(304).end();
res.type('application/json').send(body);
});
Step 3: Give consumers a firewall recipe and its limits
Consumers will ask for rules, so ship rules — and ship the caveat in the same breath, because the two travel together or the caveat gets lost. The recipe below turns the published document into an nftables set that a consumer can regenerate on a schedule.
import { request } from 'undici';
const DOC_URL = 'https://api.example.com/.well-known/webhook-egress-ranges.json';
export async function renderNftablesSet(today = new Date()): Promise<string> {
const res = await request(DOC_URL, { headersTimeout: 3_000 });
const doc = (await res.body.json()) as {
version: number;
ranges: Array<{ cidr: string; status: string; active_from: string }>;
};
// Allow active AND planned ranges: a planned range must already be permitted
// on the day the sender starts using it, not discovered after failures start.
// Fully retired blocks are removed from the document, so everything listed
// here belongs in the set.
const allowed = doc.ranges.map((r) => r.cidr);
const stale = doc.ranges.filter(
(r) => r.status === 'planned' && new Date(r.active_from) < today,
);
if (stale.length > 0) {
// The sender said this would go live already — the document is behind.
console.warn(`egress_doc_stale version=${doc.version} cidrs=${stale.map((r) => r.cidr)}`);
}
return [
'table inet filter {',
' set webhook_senders {',
' type ipv4_addr; flags interval;',
` elements = { ${allowed.join(', ')} }`,
' }',
' chain input {',
' tcp dport 443 ip saddr @webhook_senders accept',
' tcp dport 443 drop',
' }',
'}',
].join('\n');
}
Now the caveat, stated the way it should appear in your own documentation. An accepted source address proves the packet arrived from a host inside your egress infrastructure. It does not prove the request came from your dispatcher, because that infrastructure is shared — a NAT gateway, a managed proxy, or a cloud provider’s egress pool carries traffic for more than your one process. It does not prove the payload is unmodified, because anything that terminates TLS on the path can rewrite the body without changing the final hop’s address. And it does nothing against a request originating inside the consumer’s own network, which is exactly what an SSRF bug elsewhere in their estate produces.
The allowlist is a volume control: it removes the internet’s background noise from your receiver so the cryptographic checks run against a much smaller population. Keep signature verification mandatory, and consider mutual TLS for webhooks where the consumer wants the connection to carry identity rather than just the payload.
Step 4: Roll a range without breaking consumers
Eventually you need a new region, a bigger NAT footprint, or a provider migration. The rollover is a four-phase sequence, and its defining constraint is that consumers apply firewall changes on their schedule, in change windows measured in weeks.
Phase one, announce: add the new CIDR to the document as planned with an active_from at least 60 days out, bump the version, and notify every consumer who has ever fetched the document. Phase two, dual egress: on the activation date, start routing a small share of deliveries through the new range while the old range keeps carrying the rest. Phase three, drain: shift all traffic to the new range and watch the per-CIDR delivery counter go to zero on the old one. Phase four, retire: mark the old block retiring with a retires_on date, and only release the addresses after that date passes.
type Phase = 'announced' | 'dual' | 'drained' | 'retired';
interface RolloutRange {
cidr: string;
status: RangeStatus;
active_from: string;
retires_on: string | null;
}
// Which ranges must a consumer permit on a given date? Both, for the whole
// overlap — this function is the sender's and the consumer's shared truth.
export function rangesToPermit(ranges: RolloutRange[], on: Date): string[] {
return ranges
.filter((r) => {
const from = new Date(r.active_from);
const until = r.retires_on ? new Date(r.retires_on) : null;
if (r.status === 'planned') return true; // permit ahead of activation
if (until && on > until) return false; // safe to drop after retirement
return on >= from || r.status === 'retiring';
})
.map((r) => r.cidr);
}
// Gate the phase transition on evidence, not on the calendar.
export function canAdvance(
phase: Phase,
deliveriesFromOldRange: number,
consumersOnLatestVersion: number,
totalConsumers: number,
): boolean {
switch (phase) {
case 'announced':
// Do not start dual egress until nearly everyone has fetched the update.
return consumersOnLatestVersion / totalConsumers >= 0.95;
case 'dual':
return true;
case 'drained':
// Zero deliveries from the old block for a full retry horizon, not an hour.
return deliveriesFromOldRange === 0;
case 'retired':
return false;
}
}
Verification and testing
Prove the source address rather than assuming it. From a dispatch worker, curl -s https://echo.internal.example/source-address should return an address inside the published block; run it on every zone, because a single zone with a missing route entry is the classic partial failure.
import { describe, expect, it } from 'vitest';
import { inCidr, rangesToPermit } from './egress';
describe('published egress ranges', () => {
it('matches only addresses inside the block', () => {
expect(inCidr('203.0.113.2', '203.0.113.0/29')).toBe(true);
expect(inCidr('203.0.113.9', '203.0.113.0/29')).toBe(false);
expect(inCidr('198.51.100.20', '203.0.113.0/29')).toBe(false);
});
it('permits a planned range before its activation date', () => {
const ranges = [
{ cidr: '203.0.113.0/29', status: 'active' as const, active_from: '2026-01-04', retires_on: null },
{ cidr: '198.51.100.16/29', status: 'planned' as const, active_from: '2026-09-01', retires_on: null },
];
expect(rangesToPermit(ranges, new Date('2026-07-25'))).toContain('198.51.100.16/29');
});
it('keeps a retiring range until its retirement date passes', () => {
const ranges = [
{ cidr: '203.0.113.0/29', status: 'retiring' as const, active_from: '2026-01-04', retires_on: '2026-10-01' },
];
expect(rangesToPermit(ranges, new Date('2026-09-15'))).toEqual(['203.0.113.0/29']);
expect(rangesToPermit(ranges, new Date('2026-10-15'))).toEqual([]);
});
});
Then assert on the document itself in CI: fetch the published URL, parse it, and fail the build if the set of CIDRs disagrees with the addresses your infrastructure code actually allocates. A published document that drifts from reality is worse than no document, because consumers trust it. Finally, keep a per-source-address delivery counter so the drain phase has evidence: sum by (source_cidr) (webhook_deliveries_total) reaching zero for the old block is what authorises retirement, and the same telemetry pairs well with the failure signals covered in alerting on webhook delivery failures.
Failure modes and gotchas
- Publishing individual addresses instead of a block. A list of three hosts means every capacity change is a consumer-facing change. Publish a prefix with headroom from day one; consumers write one rule and you keep room to grow inside it.
- Retiring the old range on the calendar rather than on evidence. Deliveries retry for hours or days, and a queued delivery drained from the old worker pool will still originate from the old address. Wait for the per-CIDR counter to be flat at zero for longer than your maximum retry horizon, which the exponential backoff schedule defines.
- A consumer treating the allowlist as authentication. They allowlist your range, delete their signature check, and now anything sharing your NAT infrastructure can forge deliveries. State the limitation in the same document that publishes the ranges, not in a separate security page nobody reads.
- Hosting the ranges document behind the range it documents. If the endpoint that publishes the CIDRs is only reachable from inside them, a consumer recovering from a bad firewall rule cannot fetch the fix. Serve it from independent infrastructure.
- Forgetting the retry workers. Redelivery from a separate queue-consumer fleet is a delivery like any other, and if that fleet routes through a different NAT gateway, consumers see an unpublished source address only on retries — a failure that looks intermittent and takes days to diagnose.
Frequently Asked Questions
What happens to a consumer's firewall rule after we release the addresses of a retired block?
The provider returns those addresses to a shared pool, and another tenant can claim them within hours. Any consumer who never removed the rule is now trusting a stranger's host, which is worse than the state before you published anything. Keep retired addresses allocated but unattached for months after the retirement date, and only release them once fetch telemetry shows the document version carrying the removal has propagated.
A consumer terminates traffic at a CDN or WAF — can they allowlist us at all?
Not at their origin, because by then the source address belongs to their own edge and every one of your deliveries looks identical to the rest of the internet. The filter has to live at the edge tier itself, as an allow rule on the delivery path, and the origin should instead restrict itself to the edge's documented address ranges. Teams that miss this either apply a rule that blocks everything or one that silently matches nothing, so make it the first question in your integration checklist.
Should we publish IPv6 egress ranges alongside the IPv4 blocks?
Only if you are prepared to version and roll them with exactly the same discipline, because a dual-stack dispatch fleet that advertises one family will eventually connect over the other and hit a rule that was never written. The pragmatic default is to force dispatch egress to IPv4 and say so explicitly, which keeps the published surface small. If a consumer is IPv6-only, publish the block and treat it as a first-class entry in the document rather than a footnote.
How should a consumer's automation behave when the ranges document cannot be fetched?
Keep the last successfully parsed set in place and raise an alert; never apply an empty or partially parsed result. A fetch failure carries no information about which addresses are valid, so replacing a working rule set with whatever came back is how a network blip turns into a total delivery outage. Refresh on the order of hourly with a conditional request, and treat an unchanged response as a healthy heartbeat rather than a wasted call.
A large customer wants a source address dedicated to their traffic — is that worth offering?
It rarely buys them what they think it does. A dedicated address still carries no proof of who produced the payload, and it fragments your egress footprint into per-customer state that every capacity change now has to respect. When the underlying request is stronger assurance that traffic really is yours, the honest answer is a client certificate on the connection, which authenticates the sender rather than describing where it sat.
Their firewall appliance only accepts individual addresses, not prefixes — what do we give them?
Generate the expanded host list from the same published document rather than hand-writing it, and stamp it with the same version number so a stale copy is obvious. Make it clear that the expansion is a derived artefact: when you grow into unused addresses inside an already-published prefix, prefix-based consumers need no change while host-list consumers do. Those customers should therefore regenerate on a schedule and alert on any version bump, not treat the list as a one-time setup step.