Somebody asks which third parties your platform sends data to, and the answer takes a week of grepping. Eleven services hold their own vendor keys, and two of those keys sit in environment variables nobody can trace to an owner. A contractor added an analytics endpoint last spring that no diagram mentions.
What you get
You will end up with one place that decides which hosts your services may reach, which methods they may use, and which credential each call carries. You also get a log with one line per decision. This is for you if you run several services that call external APIs.
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 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, 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 | 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 | 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 | 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 | 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.
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.
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.
node demo.mjs
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.
node --test proxy.test.mjs
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
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
The form is mandatory on both sides and used on one. RFC 9112 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. ↩︎ Back to text
-
http_proxyis the one variable curl reads in lowercase only. CGI turns a request header namedProxyinto an environment variable namedHTTP_PROXY. A script that honored the uppercase form could then be pointed at any proxy by anyone who sent a header. httpoxy.org dates the discovery to March 2001 inlibwww-perland 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. ↩︎ Back to text -
Kubernetes accepts the policy either way. The documentation’s prerequisites say network policies are implemented by the network plugin, 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. ↩︎ Back to text