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

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;
}
Egress address topology Dispatch workers in three availability zones route through per-zone NAT gateways with static addresses, which together form a single published CIDR that consumer firewalls accept. One fixed address per zone, published as a single contiguous block. workers, zone a workers, zone b workers, zone c NAT gateway a 203.0.113.1 NAT gateway b 203.0.113.2 NAT gateway c 203.0.113.3 Published range 203.0.113.0/29 Consumer firewall accept rule Autoscaling changes the worker count and never the source address.
Spare addresses inside the published block are what let you add a zone later without asking every consumer to edit a firewall rule.

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);
});
Egress ranges document anatomy A JSON document listing a version, an update timestamp and two ranges, annotated with the meaning of the version field, the active status and the planned status. The document is the contract; the CIDR list is only its payload. { "version": 7, "updated_at": "2026-07-25", "ranges": [ { "cidr": "203.0.113.0/29", "status": "active" }, { "cidr": "198.51.100.16/29", "status": "planned", "active_from": "2026-09-01" } ] } monotonic version; compare, do not diff active: traffic can arrive from here today planned: allow it before we use it served with an ETag and a 5-minute TTL
Status and activation date carry the operational meaning; a bare list of CIDRs gives a consumer no safe way to prepare for a change.

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.

What each control actually proves Four properties compared across an IP allowlist, a signature check and mutual TLS, showing that only the signature proves payload integrity. Property IP allowlist Signature check Mutual TLS Payload integrity no yes no Identity on shared NAT no yes yes Rejects before CPU cost at the firewall after buffering in the handshake Breaks on infra change yes, needs rollout no on cert rotation The first row is the reason an allowlist can never stand on its own.
Read the columns as complements, not alternatives: the allowlist shrinks the attacker population, the signature authenticates what survives it.

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;
  }
}
Egress range rollover timeline Four phases spread over ninety days: announce the planned range, run both ranges in parallel, drain the old range, then release its addresses. Announce new CIDR marked planned Drain old range delivers nothing Dual egress both ranges carry real traffic Retire addresses released after retires_on day 0 day 60 day 75 day 90
Sixty days between announcement and first use is not caution, it is the length of a typical enterprise firewall change window.

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

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.