---
key: "api-gateway-inventory"
title: "Maintain an API inventory and prevent gateway bypass"
url: "https://softwaresecurity.com/domains/runtime/api-security/api-gateway-inventory/"
api: "https://softwaresecurity.com/api/v1/articles/api-gateway-inventory.json"
domain: "runtime"
topic: "Runtime, cloud \u0026 APIs"
type: "Guide"
identifiers: ["CWE-290","CWE-348","CWE-306","A01:2025","A07:2025","API9:2023","API2:2023","API8:2023","V4.1.3"]
tags: ["Proxies, caches and request smuggling","API security"]
stages: ["build","operate"]
roles: ["developer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Maintain an API inventory and prevent gateway bypass

Connect documented routes, deployed listeners and backend trust so retired or direct paths do not bypass the intended controls.

Canonical URL: https://softwaresecurity.com/domains/runtime/api-security/api-gateway-inventory/

## In short

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-User` and `X-Forwarded-For` at the gateway; nginx `proxy_set_header` replaces a client-supplied value.
- Behind a proxy, trust forwarded addresses only from that proxy: Express `trust proxy` set to `true` lets 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.

## Recommended defaults

| Setting | Value | Note | 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 |

## Verify it

1. Through the gateway, send `X-User: alice` with Bob's token and confirm the backend receives `bob`; without a token, expect 401.
2. Send a forged `X-Forwarded-For` and confirm the backend logs the real client address.
3. Connect to every legacy hostname, load balancer and internal listener directly and confirm each is refused or independently authenticated.
4. 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.
5. After retiring a version, call its old address and confirm that no request reaches the handler.

## 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

Audience: API platform engineers and backend developers who run gateways, retire API versions and decide which headers a service trusts

Before you start:

- Trace public and internal listeners, gateway routing and direct service access.

Time: About 26 minutes: 7 to read and 19 for the exercise

## A gateway protects only traffic that actually crosses it

An 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.

## Worked inventory: one invoice service, three entrances, one forgotten route

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.

```text
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 Alice
```

## Authenticate the upstream and distrust caller headers

Restrict 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.

```nginx
# 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 "";
    }
}
```

## Trust forwarded headers only from your own proxies

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.

```javascript
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');
});
```

## Validate requests without hiding parser disagreements

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.

## Remove the capability, not only the documentation

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.

## Diagnose incomplete fixes

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.

## Check the result against concrete cases

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 material

```text
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.

Worked answer: 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`?

Worked answer: 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.

Worked answer: 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.

Worked answer: Record G's allowlisted routes and authenticated caller contract, a 404 for `/v1/invoices/export` at 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.

Deliverable: Separate explanations for the T2 and T3 problems, why the traffic sample missed T2's route, why R1 is insufficient, and the inventory evidence needed for closure.

Scope: 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.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OpenAPI Specification](https://spec.openapis.org/oas/v3.2.1.html). Version 3.2.1.
- [OWASP API Security Top 10](https://api-security.owasp.org/editions/2023/en/0x11-t10/). 2023 edition.
- [RFC 9112 HTTP/1.1](https://www.rfc-editor.org/rfc/rfc9112.html). June 2022.
- [RFC 9111 HTTP caching](https://www.rfc-editor.org/rfc/rfc9111.html). June 2022.
- [OWASP ASVS 5.0 V4 API and Web Service](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x13-V4-API-and-Web-Service.md). Version 5.0.0.
- [OWASP API9:2023 Improper Inventory Management](https://api-security.owasp.org/editions/2023/en/0xa9-improper-inventory-management/)
- [nginx: ngx_http_auth_request_module](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html)
- [nginx: ngx_http_proxy_module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
- [Express: behind proxies](https://expressjs.com/en/guide/behind-proxies/)