# How to give a vendor a stable egress IP to allowlist

> Pin calls to a vendor that allowlists source addresses behind one reserved address, and prove it from inside a worker with an address echo service.

Source: https://voxgig.com/howto/give-vendors-a-stable-egress-ip

- Audience: platform-team
- Level: intermediate
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Route every call to the vendor through one egress that owns a reserved address. That egress can be a NAT gateway with an Elastic IP, a Squid or Envoy proxy bound to a fixed outgoing address, or a Cloudflare dedicated egress IP. Register that one address with the vendor. Then ask an address echo service, from inside a worker and through the same route, which address it saw, and fail the deploy when it is not the registered one.

---
## You will need

Node 22 or later, a vendor that admits traffic by source address, and a network path you can
route. Verified 2026-10-21 against Node 22.22.0. Nothing here needs a cloud account: the egress,
the vendor, and the echo service are loopback stand-ins. The addresses `127.0.0.42` and
`127.0.1.1` to `127.0.1.4` stand in for public ones, because Linux routes all of `127.0.0.0/8` to
the loopback interface. [MongoDB Atlas](https://www.mongodb.com/docs/atlas/security/ip-access-list/)
allows client connections only from entries in a project's IP access list,[^1] and
[Snowflake](https://docs.snowflake.com/en/user-guide/network-policies) network policies allow or
deny a request by its origin.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [AWS NAT gateway with an Elastic IP](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-nat-gateway.html) | Workloads inside a VPC that need one public address with no process of yours in the path | An hourly charge plus a charge per gigabyte, one gateway per Availability Zone to survive a zone failure, and so one address to register per zone | You need to see or change the request, or the workload runs outside a VPC |
| [Cloudflare Gateway egress policies](https://developers.cloudflare.com/cloudflare-one/traffic-controls/egress-policies/) | Devices and networks already on Cloudflare One, and a vendor that wants one address for people and servers alike | Dedicated egress addresses are an account add-on arranged with your account team, and every workload's traffic has to reach Gateway first, through a client, a PAC file, or a Tunnel | The traffic comes from cloud workloads with no Cloudflare on-ramp |
| [Envoy with `upstream_bind_config`](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto) | Envoy is already your egress proxy and you want the pinned address and the per-vendor policy in one process | A bind address per cluster in a configuration model with its own vocabulary, and a proxy you patch, scale, and keep up yourself | Nobody on the team runs Envoy |
| [Squid with `tcp_outgoing_address`](https://www.squid-cache.org/Doc/config/tcp_outgoing_address/) | A forward proxy you run already, on a host that holds a reserved address, with one rule per ACL | A host to patch and monitor, and a second host plus a second registered address before it stops being a single point of failure | Your traffic is HTTPS you cannot decrypt and you need more than a host rule |

The NAT gateway and the Cloudflare option buy the address as a service, and the two proxies make
it a host you run. A NAT gateway bills by the hour per zone and by the gigabyte,[^2] and it works
at the packet level. A method or path rule still needs the proxy from
[the previous page in this series](/howto/route-outbound-calls-through-an-egress-proxy). Squid
and Envoy give you the rule and the address in one process, in exchange for patching, scaling,
and a second host before the address stops being a single point of failure.

## Pin the address where the connection opens

The address a vendor sees is the source address of the TCP connection that reaches it, so
whatever opens that connection decides it. AWS puts the decision in the gateway: a public NAT
gateway [must have an Elastic IP associated at creation](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-nat-gateway.html),
and that address is the one the vendor sees. Squid puts it in a directive,
`tcp_outgoing_address 203.0.113.10`, and Envoy puts it in a cluster's `upstream_bind_config`. The
stand-in on this page does the same job with one option of `http.request`:

```js title="stands.mjs"
/**
 * The pinned egress: a forward proxy that opens every upstream connection from one reserved
 * address. A NAT gateway with an Elastic IP, Squid's tcp_outgoing_address, and Envoy's
 * upstream_bind_config all do this job. Here it is a few lines of node:http, so the page can
 * show what the vendor sees without an account at a cloud provider.
 */
export async function pinnedEgress({ address }) {
  const state = { relayed: 0 }
  const server = createServer((req, res) => {
    let target = null
    try {
      target = new URL(req.url) // absolute form, which is what a client sends to a proxy
    } catch {
      // Origin form, such as a load balancer's health check, names no upstream to relay to.
    }
    if (target?.protocol !== 'http:') return json(res, 400, { error: 'not a proxy request' })
    state.relayed++
    const up = request(
      {
        host: target.hostname,
        port: target.port,
        path: target.pathname + target.search,
        method: req.method,
        headers: req.headers,
        localAddress: address,
      },
      (ures) => {
        res.writeHead(ures.statusCode, ures.headers)
        ures.pipe(res)
      },
    )
    up.on('error', () => json(res, 502, { error: 'the egress could not reach the vendor' }))
    req.pipe(up)
  })
  const port = await listen(server)
  return { url: `http://127.0.0.1:${port}`, address, state, close: closer(server) }
}
```

[`localAddress`](https://nodejs.org/api/http.html#httprequestoptions-callback) binds the upstream
socket to the reserved address before it connects. Everything else in the function is an
ordinary forward proxy: read the absolute-form URL, answer 400 to a request without one, relay the
rest, copy the answer back. Without that 400, a load balancer health check would crash the
egress. The address is the only thing the vendor learns about your side, which is what makes it
something they can allowlist.

Register only that address with the vendor. Every other entry in their allowlist is a path around
the egress, and the list grows by one each time somebody registers a laptop to get a demo working.

## Refuse the direct route in the client

The route is a deployment setting, and settings go missing. A service, a scheduled job, or a
scaled-out worker that starts without `EGRESS_PROXY` would call the vendor from its own address
and collect the 403 from the opening paragraph. The client refuses first:

```js title="egress.mjs"
/** Thrown when a worker would call a vendor directly, from an address the vendor has never heard of. */
export class EgressNotConfigured extends Error {
  constructor(url) {
    super(`refusing to send to ${new URL(url).host} without an egress route: set EGRESS_PROXY`)
    this.name = 'EgressNotConfigured'
    this.code = 'egress_not_configured'
  }
}

/**
 * Send one request through the configured egress. `egress` is the proxy URL every production
 * worker is given as EGRESS_PROXY. Without it the call is refused rather than sent directly,
 * because a direct call leaves from whatever address this worker happens to hold today, and the
 * vendor registered the egress address, not that one. `allowDirect` is for the one call that
 * must find out what a direct call looks like: the check below. This sends plain HTTP, which is
 * what the loopback stand-ins speak; for an HTTPS vendor, put the same refusal in front of your
 * runtime's proxy support, which tunnels with CONNECT from the same egress address.
 */
export async function send(url, { egress = process.env.EGRESS_PROXY, method = 'GET', agent, allowDirect = false } = {}) {
  if (!egress && !allowDirect) throw new EgressNotConfigured(url)
  const target = new URL(url)
  const options = egress
    ? { ...hostPort(egress), path: target.href, method, headers: { host: target.host }, agent }
    : { ...hostPort(target), path: target.pathname + target.search, method, agent }
  return new Promise((resolve, reject) => {
    const req = request(options, (res) => {
      let text = ''
      res.on('data', (chunk) => (text += chunk))
      res.on('end', () => {
        try {
          const isJson = /json/.test(res.headers['content-type'] ?? '')
          resolve({ status: res.statusCode, body: isJson ? JSON.parse(text) : text })
        } catch (err) {
          reject(err)
        }
      })
    })
    req.on('error', reject)
    req.end()
  })
}
```

`send` reads `EGRESS_PROXY` and sends the request to that proxy in absolute form, with the
vendor's host in the `Host` header. Without the variable it throws `egress_not_configured` before
opening a socket. A misconfigured worker then fails on its first call with a code your alerting
can name, rather than with a vendor 403 that looks like a credential problem.

## Prove it from inside a worker

The check asks an address echo service which address it saw. It asks from inside the workload,
through the same route the vendor calls take, and compares the answer with the registered address:

```js title="egress.mjs"
/**
 * Ask an address echo service what address this worker's calls arrive from, the same way the
 * vendor calls go, and compare it with the address registered with the vendor. The answer is plain
 * text, as from checkip.amazonaws.com, or JSON with an `ip` field, as from api.ipify.org with
 * ?format=json. Run it from inside the workload, not from a laptop: the laptop's address is not
 * the one in question. It refuses to run without the registered address, and only a 200 from the
 * echo service counts as an answer, so a check that learned nothing never passes.
 */
export async function checkEgress({ echoUrl, expected, egress = process.env.EGRESS_PROXY, agent }) {
  if (typeof expected !== 'string' || !expected) throw new TypeError('checkEgress needs the registered address')
  const { status, body } = await send(echoUrl, { egress, agent, allowDirect: !egress })
  const seen = status === 200 ? (typeof body === 'string' ? body.trim() : body?.ip) || null : null
  return { seen, expected, ok: seen === expected }
}
```

Two public services answer it: [checkip.amazonaws.com](https://checkip.amazonaws.com/) returns
the address and a line break, and [`api.ipify.org`](https://www.ipify.org/) returns text or JSON.[^3] The demo runs four workers, each holding an address of its own, through the
pinned egress, then runs the check from one of them.

```bash
node demo.mjs
```

```text output
registered with billco: 127.0.0.42, the address of the pinned egress
four production workers, each holding a fresh address, all routed through the egress
  worker 127.0.1.1  200  billco saw 127.0.0.42
  worker 127.0.1.2  200  billco saw 127.0.0.42
  worker 127.0.1.3  200  billco saw 127.0.0.42
  worker 127.0.1.4  200  billco saw 127.0.0.42
distinct addresses billco saw: 1
echo service, asked from worker 127.0.1.3 through the egress: 127.0.0.42
registered with billco: 127.0.0.42, match: true
```

The vendor saw one address for four workers, and the echo service reports the same one. Run
`checkEgress` where the workload runs: as a deploy job inside the production network, with the
production `EGRESS_PROXY`, failing the rollout on `ok: false`. From a laptop it reports the
laptop's address, which is how the wrong address got registered.

## Check it worked

Seven tests pin the page's claims. Four routed workers are one address to the vendor, and the same
four are four addresses with three refusals when they call directly. The check matches only on the
registered address and throws without one. A worker with no egress configured refuses to call at
all, and the egress answers 400 to a request it cannot relay.

```bash
node --test egress.test.mjs
```

```text output
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
```

`# pass 7` with `# fail 0` is the result to expect. A failure in the second test means the
workers' own addresses reached the vendor, so the route was bypassed.

## When it goes wrong

The call that passed in staging fails once traffic spreads. Staging ran one worker, somebody
registered the address that worker happened to hold, and production spread the same code over
four workers with addresses of their own:

```bash
node pitfall.mjs
```

```text output
it worked in staging
  one staging worker, 127.0.1.1, and that address registered with billco
    worker 127.0.1.1  200
  production: traffic spreads over four workers, no egress route, same registration
    worker 127.0.1.1  200
    worker 127.0.1.2  403  source address not registered, billco saw 127.0.1.2
    worker 127.0.1.3  403  source address not registered, billco saw 127.0.1.3
    worker 127.0.1.4  403  source address not registered, billco saw 127.0.1.4
  3 of 4 calls refused, each from a worker that did not exist in staging
```

The fix is the page: route the workers through the egress and register the egress, never a
worker. Serverless reaches the same wall sooner. A Lambda function attached to a VPC reaches the
internet [through a NAT gateway](https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc-internet.html).
The same guide says that
[attaching a function to a VPC does not by itself give it internet access or a public IP](https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html).
A function left in the Lambda-managed network calls out from addresses the service chooses.

Half the fleet is refused, by zone. AWS recommends
[a NAT gateway in each Availability Zone](https://docs.aws.amazon.com/vpc/latest/userguide/nat-gateway-basics.html),
which means one address per zone. The second half of the same run shows what happens when only
one of them was registered:

```bash
node pitfall.mjs
```

```text output
one egress per zone, one address registered
  zone a leaves from 127.0.0.42, registered; zone b leaves from 127.0.0.43, not registered
    worker 127.0.1.1 in zone a  200
    worker 127.0.1.2 in zone b  403  source address not registered, billco saw 127.0.0.43
    worker 127.0.1.3 in zone a  200
    worker 127.0.1.4 in zone b  403  source address not registered, billco saw 127.0.0.43
  half the fleet refused, by zone
a worker started without EGRESS_PROXY
    EgressNotConfigured: refusing to send to 127.0.0.1:42531 without an egress route: set EGRESS_PROXY
```

Register every zone's address before that zone takes traffic, and run the check from every zone.
Cloudflare's dedicated egress addresses
[arrive at least two to an account, in two cities](https://developers.cloudflare.com/cloudflare-one/traffic-controls/egress-policies/dedicated-egress-ips/),
and traffic that matches no egress policy takes the closest one. The same rule applies there:
register both and write the catch-all policy.

Every call fails with `egress_not_configured` after a deploy. A service shipped without
`EGRESS_PROXY`, and the last two lines of that output are what it prints. Set the variable, and treat the
refusal as the client doing its job.

## When not to do this

Do not build this for a vendor that has not asked for it. A reserved address is one more thing to
keep in step between your network and their console, and a NAT gateway bills by the hour whether
or not a byte crosses it.

Do not treat the address as authentication. A source address says which network a call left from,
not which service made it, or for which customer. Keep the key, keep the per-vendor policy from the
previous page, and expect the vendor to want both.

Do not route all egress through one address because one vendor wants it. The egress is a single
point of failure and a single queue, and every vendor that never asked for a fixed address then
shares both. Route only the vendors that need it.

Do not register an address you saw from a laptop, a bastion, or a CI runner. The address that
matters is the one the workload's calls leave from, so ask from there.

## Related how-tos

- [Tell a denied outbound call apart from a vendor outage](/howto/diagnose-an-outbound-call-that-was-denied)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

- [Route outbound API calls through an egress proxy](/howto/route-outbound-calls-through-an-egress-proxy)

- [Choose between an egress gateway and an in-process library](/howto/choose-between-an-egress-gateway-and-a-library)

## Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it
printed. No NAT gateway, Squid, Envoy, or Cloudflare Gateway ran: the egress, the vendor, and the
echo service are loopback stand-ins, and the fixed addresses are loopback addresses the processes
bound to. The vendor and cloud behaviors are read from the documentation linked.

[^1]: Atlas's [instructions](https://www.mongodb.com/docs/atlas/security/ip-access-list/) begin
with "Find Your Public IP Address" and tell you to learn "the public IP address that you want to
grant access to" before you add an entry. The page is written for a person at a
browser, and the address a person finds is the browser's. It works for exactly as long as the
connections come from that chair.

[^2]: A NAT gateway is billed for [each hour it is provisioned and available](https://aws.amazon.com/vpc/pricing/),
plus a charge per gigabyte processed. Since February 2024 the address is billed as well, at
[$0.005 per public IPv4 address per hour](https://aws.amazon.com/blogs/aws/new-aws-public-ipv4-address-charge-public-ip-insights/),
"whether attached to a service or not." NAT itself was published as a stopgap.
[RFC 1631](https://www.rfc-editor.org/rfc/rfc1631.html), from May 1994, concludes that NAT "may
be a good short term solution to the address depletion and scaling problems" because it needs few
changes. The same section adds that its negative characteristics may make it inappropriate even
as a short term solution.
[RFC 3022](https://www.rfc-editor.org/rfc/rfc3022.html) replaced it in January 2001 with the word
"Traditional" added to the title. The short term solution is in its fourth decade and is sold by
the hour.

[^3]: [`api.ipify.org`](https://www.ipify.org/) says it has no request limit and logs no visitor
information. Its entire output is the address
the request came from, which is the one piece of visitor information it cannot avoid reading.
[checkip.amazonaws.com](https://checkip.amazonaws.com/) answers the same question with the
address followed by a newline, in plain text.