Calls to the vendor fail in production with a 403 that names no credential, although the same code passed in staging. Their support desk asks for your IP address, a question your platform cannot answer. The workers scale out across three zones, each instance leaves the network from whatever public address it was handed at boot, and the address you registered belonged to a staging box.
What you get
You will end up with one reserved address that every vendor call leaves from, a client that refuses any other route, and a check that asks an echo service what it saw. This is for you if a vendor admits traffic by source address.
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
allows client connections only from entries in a project’s IP access list,1 and
Snowflake 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 | 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 | 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 | 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 | 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. 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,
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:
/**
* 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 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:
/** 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:
/**
* 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 returns
the address and a line break, and api.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.
node demo.mjs
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.
node --test egress.test.mjs
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:
node pitfall.mjs
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. The same guide says that attaching a function to a VPC does not by itself give it internet access or a public IP. 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, which means one address per zone. The second half of the same run shows what happens when only one of them was registered:
node pitfall.mjs
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, 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
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.
Footnotes
-
Atlas’s instructions 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. ↩︎ Back to text
-
A NAT gateway is billed for each hour it is provisioned and available, plus a charge per gigabyte processed. Since February 2024 the address is billed as well, at $0.005 per public IPv4 address per hour, “whether attached to a service or not.” NAT itself was published as a stopgap. RFC 1631, 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 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. ↩︎ Back to text
-
api.ipify.orgsays 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 answers the same question with the address followed by a newline, in plain text. ↩︎ Back to text