List every way to reach each backend, not just the routes in the gateway or the OpenAPI file: old hostnames, direct load balancers and internal listeners. Close or authenticate each extra path. At the gateway, replace identity and forwarding headers instead of passing client values through, and make the backend trust those headers only from the gateway, preferably over an authenticated channel. Retire an API by removing its listener and credentials, not only its DNS name or documentation.
- A gateway protects only traffic that crosses it; a legacy listener or internal port can reach the same handler without its checks.
- Overwrite headers such as
X-UserandX-Forwarded-Forat the gateway; nginxproxy_set_headerreplaces a client-supplied value. - Behind a proxy, trust forwarded addresses only from that proxy: Express
trust proxyset totruelets any client choose its address. - Removing DNS does not remove a listener; prove retirement by testing the old address directly.
- Compare the OpenAPI document, framework route registration, gateway configuration and traffic; any one of them alone misses routes.
| Setting | Value | Source |
|---|---|---|
Identity headers such as X-User | Set only by the gateway from verified credentials, replacing any client value | OWASP ASVS 5.0 V4 API and Web Service |
X-Forwarded-For at the first proxy | proxy_set_header X-Forwarded-For $remote_addr (overwrite) | nginx: ngx_http_proxy_module |
Express trust proxy | The addresses or subnets of your proxies, never true on an internet-facing service | Express: behind proxies |
| Backend reachability | Only from the gateway, authenticated with mTLS or a signed gateway token | OWASP API Security Top 10 |
- Through the gateway, send
X-User: alicewith Bob's token and confirm the backend receivesbob; without a token, expect 401. - Send a forged
X-Forwarded-Forand confirm the backend logs the real client address. - Connect to every legacy hostname, load balancer and internal listener directly and confirm each is refused or independently authenticated.
- Diff the OpenAPI paths against the framework route table and the gateway routes, and assign an owner to every route that appears in only one of them.
- After retiring a version, call its old address and confirm that no request reaches the handler.
- Applies to
- nginx 1.30.5 (gateway configuration tested)
- Express 5.2.1 (trust proxy tested)
API exposure inventory and bypass prevention. Include old versions, internal routes and out-of-band exports.
- Part of
- Learning path: Runtime and cloud defense (step 7 of 13). Workflows: Fix a finding, Test an application
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Trace all routes to one backend rather than counting documented APIs
- Find an identity-header trust failure on a bypass route
- Find routes the specification and traffic miss by comparing route registration with gateway configuration
Before you start
API platform engineers and backend developers who run gateways, retire API versions and decide which headers a service trusts
- Trace public and internal listeners, gateway routing and direct service access.
Refresh first: Enforce authorization for every object action
Go to the guided practiceAn API may be reachable through a public gateway, an old hostname, a direct load balancer and an internal listener. A policy enforced on one path does not automatically apply to the others. Inventory hosts, routes, methods, versions, owners, data sensitivity and consumers using both deployment configuration and observed traffic.
An OpenAPI document is a useful contract, but it can omit routes or describe a version no longer deployed. Compare the specification with framework route registration, gateway configuration and a bounded traffic sample. Record undocumented administrative/debug paths and routes with no clear owner. Absence from a traffic sample is not proof that an endpoint is unused.
Begin with the service, then enumerate ways to reach it. The example has a public gateway, a legacy load balancer and a direct internal listener. They reach the same handler but do not supply the same authentication evidence. The gateway validates a token and replaces X-User with the authenticated account. The legacy listener passes that header unchanged. If the handler accepts X-User without authenticating its immediate caller, anyone who reaches legacy can choose an identity. The presence of a secure gateway route does not constrain that second entrance.
Closing legacy DNS alone does not necessarily close its listener. Clients may retain an address, use another hostname, or connect directly where the network permits. Remove or restrict the listener and verify the old route no longer reaches the handler. On the supported path, establish how the backend identifies an authorized gateway and protects identity context from modification. An internal network address is not a durable substitute for authenticated delegation.
The inventory also finds a route nobody listed. The published OpenAPI document describes only v2, but the framework still registers GET /v1/invoices/export, and the gateway forwards every /v1/ path to the service. The route had no requests in the seven-day traffic sample, so a traffic-based inventory missed it. Any authenticated user can call it through the supported gateway and receive every invoice in their tenant, with the card and billing fields that v2 removed: OWASP API9:2023 improper inventory management, combined with the excessive data exposure that v2 was written to fix. Neither the gateway's token check nor the forwarded-header controls help, because the caller is who they claim to be. The repair is to remove the handler and route the gateway on an allowlist of documented paths rather than on a version prefix.
ROUTE INVENTORY (complete for this exercise)
G: api.example.test -> gateway -> invoices; verifies token; replaces X-User; forwards /v1/* and /v2/*
L: old.example.test -> legacy listener -> invoices; passes X-User unchanged
I: internal:8080 -> invoices; reachable by arbitrary application workloads
Handler: trusts X-User; returns invoice if owner == X-User
OpenAPI 2.3 (published): GET /v2/invoices/{id}, GET /v2/invoices?page=
Framework routes: GET /v2/invoices/{id}, GET /v2/invoices, GET /v1/invoices/{id}, GET /v1/invoices/export
Traffic, last 7 days: /v2/* 98%, /v1/invoices/{id} 2%, /v1/invoices/export 0 requests
/v1/invoices/export: every invoice in the caller's tenant, including card_last4 and billing_address
Invoice 7 belongs to AliceRestrict backend reachability to the intended callers and authenticate the gateway-to-service path where appropriate. Decide which component validates external credentials and how a backend verifies the resulting identity context. Do not trust a caller-supplied user, role or tenant header simply because normal clients receive it from a gateway.
Strip or replace reserved forwarding and identity headers at the trusted boundary, using the proxy's documented configuration. Derive client address and external host/scheme only through configured trusted proxies. Preserve application object/function authorization behind the gateway: a validated token proves an identity under a protocol, not permission for every requested record, as the object-level authorization guide at /domains/appsec/authorization/object-level-authorization/ explains.
The nginx gateway below authenticates each request with an auth_request subrequest, then sets X-User from the verified result and X-Forwarded-For from the connection, so a client cannot inject either. In a test, a request with Bob's token and a forged X-User: alice reached the backend as bob, a forged X-Forwarded-For was replaced by the real address, and a request without a token got 401. The backend must still refuse callers other than the gateway, for example with mutual TLS or by verifying a token the gateway signs, because anyone who reaches it directly can send any header.
# API gateway (nginx 1.30): authenticate, then replace identity and forwarding headers so
# nothing a client sends under those names reaches the backend.
upstream invoices { server backend:8080; }
upstream auth { server backend:9000; }
server {
listen 8443;
location /v1/ {
auth_request /_auth; # 401 unless the token is valid
auth_request_set $auth_user $upstream_http_x_auth_user;
proxy_set_header X-User $auth_user; # replaces any client X-User
proxy_set_header X-Forwarded-For $remote_addr; # first hop: overwrite, never append
proxy_set_header Authorization ""; # the backend trusts the gateway, not the token
proxy_pass http://invoices;
}
location = /_auth {
internal;
proxy_pass http://auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
}Frameworks derive the client address and protocol from X-Forwarded-* headers only when told which hops to trust. In Express, trust proxy takes the addresses or subnets of your proxies. The tests below show the three cases: with only the gateway trusted, a direct caller's forged X-Forwarded-For was ignored; from a trusted proxy the reported address was used; and with trust proxy set to true, any client could choose its address. Rate limits, audit logs and allowlists that use req.ip inherit whichever of these you configure.
import test from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
async function clientIp(trustProxy, headers) {
const app = express();
app.set('trust proxy', trustProxy);
app.get('/ip', (req, res) => res.json({ip: req.ip}));
const server = await new Promise(r => {
const s = app.listen(0, '127.0.0.1', () => r(s));
});
const res = await fetch(`http://127.0.0.1:${server.address().port}/ip`, {headers});
server.close();
return (await res.json()).ip;
}
test('a caller that is not a trusted proxy cannot choose its address', async () => {
// Only the gateway at 10.1.2.3 may set X-Forwarded-For; this caller connects from loopback.
assert.equal(await clientIp('10.1.2.3', {'x-forwarded-for': '203.0.113.9'}), '127.0.0.1');
});
test('the address a trusted proxy reports is used', async () => {
assert.equal(await clientIp('loopback', {'x-forwarded-for': '203.0.113.9'}), '203.0.113.9');
});
test('trusting every hop lets any client spoof its address', async () => {
assert.equal(await clientIp(true, {'x-forwarded-for': '203.0.113.9'}), '203.0.113.9');
});Apply required content types, schema constraints, size limits and method policies, then keep equivalent checks where application behavior depends on them. Gateway and backend parsers can disagree on normalization, duplicate fields or HTTP framing. Use maintained implementations and reject ambiguous inputs according to the supported protocol. Test upgrades and HTTP-version translation paths, including the reverse proxy.
Route on an allowlist of documented methods and paths, not on prefixes such as /v1/, so a handler that survives in the framework cannot be reached just because the gateway forwards its prefix. Shared caches in front of the gateway are a separate boundary with their own failure modes; the cache poisoning and deception guide at /domains/runtime/edge-defense/cache-poisoning-deception/ covers cache keys and private responses.
Before retiring an API version, identify consumers, communicate a migration path and set a measurable removal condition. Remove routes, listeners, DNS references, credentials and obsolete monitoring exceptions as applicable; verify the old path cannot reach the backend directly. Retain a bounded operational signal for unexpected use without keeping the vulnerable implementation alive indefinitely. Retire associated data and access grants through the relevant retention policy.
An inventory created exclusively from traffic misses dormant routes and unusual methods. Compare route registration, deployment listeners and gateway configuration, then use traffic to identify consumers and frequency. Record the observation window; “not seen in seven days” does not mean unused.
Do not blindly trust every forwarded header after adding one proxy to a trusted list. Define which hops may set each value and how incoming client values are removed or overwritten. Check the actual proxy and framework behavior, including multiple values, rather than assuming a header name implies provenance.
These outcomes belong to the worked example; the gateway and trust-proxy tests above check the header rows, and the listener rows need a test against your own network.
| Case | Expected result | What it establishes |
|---|---|---|
| Alice requests invoice 7 through the supported route | Alice receives invoice 7 | Required behavior works |
Bob calls GET /v1/invoices/export through the gateway | 404 at the gateway, and no such route in the service | The undocumented route is removed, not only unlisted |
Caller sends X-User: Alice on legacy | Cannot acquire Alice’s identity | Bypass path is closed or independently authenticated |
| Unapproved workload calls internal listener | Denied before trusted identity context is accepted | Backend verifies its caller |
Guided practice
Use the complete route inventory to repair two independent problems. The request trace below is sufficient to identify both.
Supplied scenario material
T1 Alice GET /v2/invoices/7 through G: 200, owner=Alice.
T2 Bob GET /v1/invoices/export through G with his own valid token: 200, 4,812 invoices for his tenant, including card_last4.
T3 unauthenticated caller through L sends X-User: Alice: 200, owner=Alice.
Proposed repair R1: delete old.example.test DNS only.
R2: remove the /v1 handlers; route G on an allowlist of the documented v2 paths; disable L; require authenticated gateway identity at I; keep object authorization.1. Locate each decision
Explain why T2 and T3 succeed through different weaknesses.
Expected result and reasoning
T2 is an inventory gap: an undocumented v1 route is still registered and reachable because the gateway forwards the whole
/v1/prefix; the caller is authenticated and is who they claim to be. T3 reaches the handler through a path that accepts forged identity context. They need separate controls.2. Explain the missed route
Why did the traffic-based inventory miss
/v1/invoices/export?Expected result and reasoning
It had no requests in the seven-day sample. Absence from traffic does not mean a route is gone; only comparing framework route registration and gateway configuration with the specification shows it.
3. Assess the incomplete retirement
Explain why R1 does not prove T3 is fixed.
Expected result and reasoning
DNS removal does not establish that the legacy listener or other direct routes are unreachable. Verify listener and network changes and test an old or direct address within the permitted test scope.
4. Record an inventory closure
List the route evidence needed beyond one passing gateway request.
Expected result and reasoning
Record G's allowlisted routes and authenticated caller contract, a 404 for
/v1/invoices/exportat G and in the service, L's removed or blocked listener, I's authenticated caller restriction and object-level tests. Keep owners and consumers so a later deployment cannot silently reintroduce v1 or L.
The route inventory and trace are supplied; the gateway configuration and tests on this page show how to check the same header and bypass decisions on a real path.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OpenAPI Specification. Version 3.2.1.
- OWASP API Security Top 10. 2023 edition.
- RFC 9112 HTTP/1.1. June 2022.
- RFC 9111 HTTP caching. June 2022.
- OWASP ASVS 5.0 V4 API and Web Service. Version 5.0.0.
- OWASP API9:2023 Improper Inventory Management
- nginx: ngx_http_auth_request_module
- nginx: ngx_http_proxy_module
- Express: behind proxies
Reference information
- SDLC phase
- DevelopmentMaintenance
- ASVS 5.0.0 requirements
- v5.0.0-4.1.3