# How to route outbound API calls through an egress proxy

> Send every outbound call through one proxy that holds the credentials, enforces a per-vendor policy, and logs every decision it makes.

Source: https://voxgig.com/howto/route-outbound-calls-through-an-egress-proxy

- Audience: platform-team
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## Short answer

Point every service at one forward proxy and give the proxy a policy entry per vendor: the hosts it may reach, the methods it may use, and the credential to attach. The calling code then holds no secrets, a host nobody approved is refused rather than silently reached, and one log answers what left the network and why.

---
## You will need

Node 22 or later, and services whose outbound destinations you can configure. The proxy reads
requests in the [absolute form](https://www.rfc-editor.org/rfc/rfc9112.html#name-absolute-form) that
an HTTP proxy is sent, which puts the target host in the proxy's hands rather than in the client's.[^1]
Most runtimes read proxy settings from [the conventional environment
variables](https://about.gitlab.com/blog/we-need-to-talk-no-proxy/), and the details of how they
parse them differ enough to be worth testing.[^2]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A forward proxy with per-vendor policy](https://www.rfc-editor.org/rfc/rfc9110.html#name-intermediaries) | Several services, several vendors, and a need to answer what leaves the network | A hop in every call path, and a component whose failure stops all outbound traffic | One service calling one vendor |
| [A shared client library](https://nodejs.org/api/http.html#httprequestoptions-callback) | One language across your services, and teams that upgrade willingly | A version per service, so policy changes land whenever each team upgrades | Services in three languages, or a policy that must apply today |
| [An API gateway for outbound traffic](https://docs.konghq.com/gateway/latest/) | You already run one and want plugins for retries, quotas and transformation | A heavier component and a configuration model to learn | The requirement is a host allowlist and a credential store |
| [Network-level egress rules](https://kubernetes.io/docs/concepts/services-networking/network-policies/) | Enforcement no running code can talk its way around | Rules by address and port, so they cannot see a method or a path | You need per-vendor credentials or method rules |

These are layers rather than rivals, and the strong setups run two. Network rules decide what can be
reached at all, and they cannot tell a `GET` from a `DELETE`.[^3] A proxy decides what may be done once
a host is reachable, and anything that ignores the proxy setting can go around it. Put the coarse
rule in the network and the specific one in the proxy.

The credential argument is the one that usually settles it. A proxy that attaches the vendor key
means no service image, environment variable, or crash dump contains one. That is a real reduction
in how many places a secret can leak from, and it costs you a component that must be up.

## Decide on host, method, and credential together

One policy entry per vendor, holding all three.

```js title="demo.mjs"
const proxy = await egressProxy({
  log,
  policy: {
    [meters.host]: { name: 'meterco', methods: ['GET'], credential: 'sk_live_meterco' },
    [billing.host]: { name: 'billco', methods: ['GET', 'POST'], credential: 'sk_live_billco' },
  },
})
```

Methods belong in the policy because read access and write access to a vendor are different grants.
A reporting service that needs to list invoices has no business creating one, and the difference is
one array.

Refuse by default. A host with no entry is refused rather than passed through, which is what makes
the log a complete answer to what your platform talks to.

Keep the policy in version control, reviewed like code. A new vendor then arrives as a pull request
with a name on it. That beats a ticket plus a conversation in a channel nobody can search.

## Strip what the client sent, then attach what the vendor needs

The proxy must not forward a credential a caller supplied.

```js title="proxy.mjs"
const stripHopByHop = (headers) => {
  const out = { ...headers }
  for (const name of ['connection', 'proxy-authorization', 'authorization', 'te', 'upgrade']) delete out[name]
  return out
}
```

Forwarding whatever arrived would let a service reach a vendor with a key it obtained some other
way, which defeats the point of centralizing them. Strip it, then attach the credential the policy
names.

Log the decision rather than the payload. One line with the vendor, the method, and the path answers
most questions, and it does not turn your log store into a copy of every request body you have ever
sent.

## Check it worked

Four attempts against two vendors, one of them uninvited.

```bash
node demo.mjs
```

```text output
GET   /meters    200 meterco saw the credential: true
POST  /meters    403 POST is not allowed for meterco
POST  /invoices  200 billco saw the credential: true
GET   /collect   403 host analytics.example.com is not in the policy
```

The two refusals are different failures and both are useful. The method refusal is a service asking
for a grant nobody gave it, which is a conversation with that team. The host refusal is the
analytics endpoint from the opening paragraph, and the proxy found it the first time anybody ran the
code.

```bash
node --test proxy.test.mjs
```

```text output
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 210.228383
```

## When it goes wrong

Traffic bypasses the proxy entirely. A library reads its own configuration, or a service ignores the
proxy variables. Enforce reachability at the network layer, and the proxy becomes the only route
rather than the polite one.

Everything fails at once. The proxy is a single point of failure you have just created. Run more
than one, health check them, and decide in advance whether an outage means failing calls or
bypassing policy.

Encrypted traffic cannot be inspected. A proxy handling `CONNECT` sees a host and nothing else,
which is enough for a host rule and not for a method rule. End the encrypted connection at the proxy
for vendors where you need more, and accept the host rule elsewhere.

The log fills with health checks. Poll traffic drowns the interesting lines. Sample the allowed
decisions, keep every refusal, and alert on a refusal for a host nobody has seen before. That alert
is the one that finds the analytics endpoint again next year.

## When not to do this

Do not add a proxy for one service calling one vendor. The component, its deployment, and its
failure modes cost more than the environment variable it replaces. Wait for the third vendor.

Do not use the proxy to hide which vendors a service uses from the team that owns it. The policy
file is the architecture diagram now, and hiding it moves the problem from the code to a review
nobody can do.

Do not put business logic in the proxy. A rewrite rule that fixes one vendor's odd pagination is
invisible from the calling code. The next person debugs the service for a day before thinking to
look here.

## Related how-tos

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Rate limit by API key and tenant, not client IP](/howto/key-rate-limits-by-api-key-and-tenant)

## Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

[^1]: The form is mandatory on both sides and used on one.
[RFC 9112](https://www.rfc-editor.org/rfc/rfc9112.html#section-3.2.2) requires a client talking
to a proxy to send the target URI in absolute form. It then requires every server to accept that
form, even though most HTTP/1.1 clients will only send it to a proxy. Every conforming origin
server is prepared for a request shape that, by the specification's own account, almost nobody
sends.

[^2]: `http_proxy` is the one variable curl reads
[in lowercase only](https://everything.curl.dev/usingcurl/proxies/env.html#http_proxy-in-lower-case-only).
CGI turns a request header named `Proxy` into an environment variable named `HTTP_PROXY`. A script
that honored the uppercase form could then be pointed at any proxy by anyone who sent a header.
[httpoxy.org](https://httpoxy.org/) dates the discovery to March 2001 in `libwww-perl` and the
following month in curl. It lists the vulnerability identifiers assigned when the same bug was
found again in 2016, in PHP, Go, and the Apache web server among others. Fifteen years is a long
time for a fix to stay in one place.

[^3]: Kubernetes accepts the policy either way. The documentation's prerequisites say
[network policies are implemented by the network plugin](https://kubernetes.io/docs/concepts/services-networking/network-policies/),
and that creating a NetworkPolicy resource without a controller that implements it will have no
effect. The resource exists, and the traffic it describes carries on. A rule with no enforcer is
indistinguishable from a rule, until something tests it.