System Design (High-Level Design) Module 4 – Networking for system designers
Forward proxies, reverse proxies and gateways
What forward proxies, reverse proxies and API gateways each do, which work a gateway can take off your services, and why business logic stays out of it.
What you will learn
- Distinguish forward proxies, reverse proxies and API gateways
- List the cross-cutting work a reverse proxy or gateway can offload
- Trace how a proxy changes a request's path and headers
- Avoid turning the gateway into a business-logic monolith
Before you start
On this page
A forward proxy works for clients: they are configured to send their requests to it, and it fetches from the Internet on their behalf, which lets an organisation control and log what leaves its network. A reverse proxy works for servers: clients think it is the server, and it passes each request to one of the machines behind it, ending TLS, routing by path, balancing load and caching on the way. An API gateway is a reverse proxy specialised for APIs: it authenticates callers, enforces rate limits and validates requests, so that the services behind it do not each have to. What none of them should hold is business logic, because a gateway that every team must change becomes the bottleneck of every release.
This lesson shows the difference, runs a small reverse proxy and a gateway’s filter chain, and draws the line between what the gateway does and what the services do.
Who a proxy works for
A forward proxy works for clients; a reverse proxy works for servers
Text description of the diagram
Two groups show where each kind of proxy sits.
Forward proxy, chosen by the clients: - office laptops send every outbound request to the forward proxy; - the proxy applies the organisation's rules, such as which sites are allowed, logs the requests and may cache shared responses; - it fetches from any website on the clients' behalf.
Reverse proxy or API gateway, chosen by the servers: - users anywhere send requests to one public name, which belongs to the reverse proxy; - the proxy terminates TLS, checks authentication and rate limits, and routes by path; - requests for /api/orders go to the orders service and requests for /api/users to the users service.
HTTP’s own definitions draw the line by who chooses the intermediary. A proxy is chosen by the client, usually through its configuration, and organisations use one to send all their requests through a common point for security, annotation or a shared cache. A gateway, also called a reverse proxy, acts as the origin server toward the client but forwards the requests inbound to other servers; it is used to put a front on older or untrusted services, to cache as an accelerator and to spread load across machines. A tunnel relays bytes blindly, as when TLS passes through a firewall proxy (RFC 9110).
In system design the three names map onto three jobs:
- A forward proxy sits next to the clients: an office’s outbound proxy, a scraper’s pool of egress addresses, a build system’s package cache. It decides which destinations are allowed and sees every request leaving.
- A reverse proxy sits next to the servers and owns the public name. Clients never learn how many machines stand behind it, which also means they cannot reach those machines directly.
- An API gateway is a reverse proxy with API-specific work added: credentials, quotas, request validation, versions and routing to many services behind one address.
What a reverse proxy or gateway can take off your services
The gateway offloading pattern lists the shared work a gateway does once instead of in every service: TLS termination and the client-facing certificate, authentication, logging and monitoring, protocol translation and throttling (Azure Architecture Center). The gateway routing pattern adds one public endpoint that sends requests to many services by path or other request properties, so services can move or split without clients noticing (Azure Architecture Center). In practice, the list looks like this:
| Work | Why it belongs at the edge |
|---|---|
| Ending TLS and holding the certificate | One certificate to renew instead of one per server |
| Routing by host and path | Services can be split, moved or renamed behind one public name |
| Load balancing and health checks | A failed server leaves the pool without clients noticing |
| Authentication | The same token check for every API, done once |
| Rate limits and quotas | Excess traffic is refused before it costs a service anything |
| Size limits and timeouts | Oversized or stalled requests stop at the door |
| Compression and caching | Less work and fewer bytes for every service |
| Request IDs and tracing headers | Every request can be followed through the system |
Each item has a price. The gateway adds a network hop and some processing time to every request, it must scale for peak traffic, it needs several instances so that it is not a single point of failure, and because it holds the security checks for everything behind it, it is a valuable target that has to be hardened. The same guidance adds two rules that are easy to miss: make sure backends accept traffic only from the gateway, so nobody can walk around it, and re-encrypt traffic from the gateway to the backends instead of forwarding plain HTTP (Azure Architecture Center).
HTTP Header Checker See the headers a site's edge sends back, including the Via and caching headers its proxies add.A reverse proxy, step by step
This program is the core of a reverse proxy without the sockets. It picks a backend by the longest matching path prefix, removes the prefix, rewrites the headers, calls the backend with a 2-second timeout and passes the answer back. The backends are functions with made-up response times, so the trace is the same on every run:
// The core of a reverse proxy, without sockets: pick a backend by the longest matching path prefix, rewrite the path
// and the headers, call the backend with a timeout, and pass the answer back. The backends are functions and the
// clock is simulated, so the program prints the same trace on every run.
const ROUTES = [
{ prefix: '/api/orders', backend: 'orders' },
{ prefix: '/api/users', backend: 'users' },
{ prefix: '/', backend: 'web' },
];
const TIMEOUT_MS = 2000;
// Fields that describe one connection, not the message: never forwarded (HTTP's hop-by-hop fields).
const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'te', 'transfer-encoding', 'upgrade'];
const BACKENDS = {
orders: (req) => (req.path === '/slow' ? { status: 200, ms: 3000 } : { status: 200, ms: 40 }),
users: (req) => (req.path === '/crash' ? { status: 'garbled', ms: 5 } : { status: 200, ms: 25 }),
web: () => ({ status: 200, ms: 8 }),
};
function matchRoute(path) {
const fits = (p) => p === '/' || path === p || path.startsWith(p + '/');
return ROUTES.filter((r) => fits(r.prefix)).sort((a, b) => b.prefix.length - a.prefix.length)[0] ?? null;
}
function forwardHeaders(req) {
const listed = (req.headers.connection ?? '').split(',').map((s) => s.trim().toLowerCase());
const out = {};
for (const [name, value] of Object.entries(req.headers)) {
if (HOP_BY_HOP.includes(name) || listed.includes(name)) continue;
if (name === 'x-forwarded-for') continue; // set by the client, so it proves nothing: this proxy is the edge
out[name] = value;
}
out['x-forwarded-for'] = req.clientIp;
out.forwarded = `for=${req.clientIp};proto=https;host=${req.headers.host}`;
out.via = '1.1 edge-proxy';
return out;
}
function proxy(req) {
const route = matchRoute(req.path);
if (!route) return { status: 404, note: 'no route' };
const path = route.prefix === '/' ? req.path : req.path.slice(route.prefix.length) || '/';
const upstream = { method: req.method, path, headers: forwardHeaders(req) };
const answer = BACKENDS[route.backend](upstream);
let status = answer.status;
let note = `${route.backend} answered in ${answer.ms} ms`;
if (answer.ms > TIMEOUT_MS) [status, note] = [504, `${route.backend} gave no answer within ${TIMEOUT_MS} ms`];
else if (typeof answer.status !== 'number') [status, note] = [502, `${route.backend} sent an invalid response`];
return { status, note, route: route.backend, upstream };
}
const requests = [
{ path: '/api/orders/42', headers: { host: 'shop.example', connection: 'keep-alive, x-debug', 'keep-alive': 'timeout=5', 'x-debug': '1' } },
{ path: '/api/orders-export', headers: { host: 'shop.example' } },
{ path: '/api/users/7', headers: { host: 'shop.example', 'x-forwarded-for': '10.0.0.1' } },
{ path: '/api/orders/slow', headers: { host: 'shop.example' } },
{ path: '/api/users/crash', headers: { host: 'shop.example' } },
];
for (const [i, r] of requests.entries()) {
const res = proxy({ method: 'GET', clientIp: '203.0.113.7', ...r });
console.log(`GET ${r.path} -> ${res.route ?? 'none'} ${res.upstream ? res.upstream.path : ''}`);
if (res.upstream) {
const sent = res.upstream.headers;
const dropped = Object.keys(r.headers).filter((h) => !(h in sent));
const added = Object.keys(sent).filter((h) => !(h in r.headers));
if (dropped.length) console.log(` dropped: ${dropped.join(', ')}`);
console.log(` added: ${added.join(', ')}`);
if (i === 0) console.log(` forwarded: ${sent.forwarded}`);
if (r.headers['x-forwarded-for']) console.log(` x-forwarded-for: the client sent ${r.headers['x-forwarded-for']}, the proxy sent ${sent['x-forwarded-for']}`);
}
console.log(` answer: ${res.status} (${res.note})`);
} Output
GET /api/orders/42 -> orders /42 dropped: connection, keep-alive, x-debug added: x-forwarded-for, forwarded, via forwarded: for=203.0.113.7;proto=https;host=shop.example answer: 200 (orders answered in 40 ms) GET /api/orders-export -> web /api/orders-export added: x-forwarded-for, forwarded, via answer: 200 (web answered in 8 ms) GET /api/users/7 -> users /7 added: forwarded, via x-forwarded-for: the client sent 10.0.0.1, the proxy sent 203.0.113.7 answer: 200 (users answered in 25 ms) GET /api/orders/slow -> orders /slow added: x-forwarded-for, forwarded, via answer: 504 (orders gave no answer within 2000 ms) GET /api/users/crash -> users /crash added: x-forwarded-for, forwarded, via answer: 502 (users sent an invalid response)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node reverse_proxy.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Each request shows one behaviour that a real proxy has:
- The prefix is cut off.
/api/orders/42reaches the orders service as/42, the way nginx replaces the matched part of the path whenproxy_passnames a path of its own (nginx documentation). - Prefixes match whole segments.
/api/orders-exportdoes not belong to the orders service; it falls through to the web backend. Matching on plain string starts is a classic routing bug. - Hop-by-hop headers are dropped. The
Connectionheader lists fields that describe one connection, and an intermediary must remove those fields andConnectionitself before forwarding (RFC 9110). Here that removedkeep-alive, the debugging header the client had listed, andConnectionitself. - The proxy says who asked. The backend sees the proxy’s address, not the client’s, so the proxy adds the client’s
address in
X-Forwarded-Forand the standardForwardedheader, which can also carry the protocol and the original host (RFC 7239), and it adds itself toVia, which HTTP asks gateways to send on every request they forward. - A client cannot vouch for itself. The third request arrived with
X-Forwarded-For: 10.0.0.1. The proxy at the edge replaced it with the address of the connection it accepted, because the header standard warns that every node on the path, the client included, can write anything into it. - The proxy answers for a broken backend. A backend that is too slow gets a 504 from the proxy; one that sends an invalid response gets a 502.
That warning matters beyond this example. If an app behind several proxies reads the leftmost X-Forwarded-For
entry, any client can choose its own address for rate limits, logs and access rules. The trustworthy entry is the
one your own outermost proxy wrote; the ones before it are claims. The gateway guidance says the same in general
terms: unverified forwarded headers are never proof of identity
(Azure Architecture Center).
The filter chain of a gateway
The filter chain of an API gateway
Text description of the diagram
A client sends an HTTPS request to the API gateway, which passes it through five filters in order: 1. it ends TLS; 2. it applies size and timeout limits; 3. it authenticates the caller, and answers 401 if the credential is wrong; 4. it applies the rate limit of the verified caller, and answers 429 if the caller is over it; 5. it routes the request by its path and adds tracing headers.
Only then does the request reach the services, together with the verified identity. The business rules live in the services, not in the gateway.
A gateway runs each request through a chain of filters, and their order is a design decision. Cheap checks that refuse bad requests should come first, and anything keyed by identity must run after the identity is proven. This program runs the same traffic through two orders. A real tenant sends a request every half second; an attacker who knows the tenant’s name but not its key sends 20 requests in one second in that name:
// An API gateway's filter chain in two orders. Every request names a tenant and carries an API key; the rate limit
// is a token bucket per tenant (5 requests of burst, 1 more each second). An attacker who knows a tenant's name but
// not its key sends a burst in that tenant's name. The clock is simulated, so every run prints the same counts.
const KEYS = { acme: 'k-acme-7f3' };
const BURST = 5;
const PER_SECOND = 1;
function authenticate(req) {
return KEYS[req.tenant] === req.key ? null : 401;
}
function rateLimiter() {
const buckets = new Map();
return (req) => {
const b = buckets.get(req.tenant) ?? { tokens: BURST, at: 0 };
b.tokens = Math.min(BURST, b.tokens + (req.t - b.at) * PER_SECOND);
b.at = req.t;
buckets.set(req.tenant, b);
if (b.tokens < 1) return 429;
b.tokens -= 1;
return null;
};
}
function run(order, requests) {
const limit = rateLimiter();
const filters = { authenticate, 'rate limit': limit };
const counts = {};
for (const req of requests) {
let status = 200; // reached the service
for (const name of order) {
const refused = filters[name](req);
if (refused) {
status = refused;
break;
}
}
const c = (counts[req.who] ??= { sent: 0, 200: 0, 401: 0, 429: 0 });
c.sent += 1;
c[status] += 1;
}
return counts;
}
// The real tenant: one request every half second for 5 seconds. The attacker: 20 requests in the first second.
const requests = [];
for (let i = 0; i < 10; i++) requests.push({ t: i * 0.5, who: 'acme', tenant: 'acme', key: 'k-acme-7f3' });
for (let i = 0; i < 20; i++) requests.push({ t: i * 0.05, who: 'attacker', tenant: 'acme', key: 'guess' });
requests.sort((a, b) => a.t - b.t || (a.who < b.who ? -1 : 1));
for (const order of [['authenticate', 'rate limit'], ['rate limit', 'authenticate']]) {
console.log(`order: ${order.join(', then ')}`);
for (const [who, c] of Object.entries(run(order, requests))) {
console.log(` ${who.padEnd(9)} sent ${String(c.sent).padStart(2)}: served ${String(c[200]).padStart(2)}, 401 ${String(c[401]).padStart(2)}, 429 ${String(c[429]).padStart(2)}`);
}
} Output
order: authenticate, then rate limit acme sent 10: served 9, 401 0, 429 1 attacker sent 20: served 0, 401 20, 429 0 order: rate limit, then authenticate acme sent 10: served 5, 401 0, 429 5 attacker sent 20: served 0, 401 4, 429 16
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node gateway_chain.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
With authentication first, the attacker’s requests are refused with 401 before they touch anything, and the tenant gets 9 of its 10 requests through; the tenth is a genuine 429, because the tenant sends two requests a second against a limit of one a second with a burst of five. With the rate limit first, the limiter counts requests under the name they claim, so the attacker spends the tenant’s allowance: the tenant is served only 5 times and refused 5 times, although the attacker never got past authentication. Nothing reached a service that should not have, and still the tenant had an outage. Key a limit on what the gateway has verified, or on the connection’s address for anonymous traffic, never on a claim inside the request.
Keep business logic out of the gateway
The offloading guidance is blunt about it: never put business logic in the gateway. The pattern is a poor fit when the gateway would need service-specific logic or routing rules that tie each backend change to a gateway change, because then backend updates force gateway releases (Azure Architecture Center). The failure grows slowly. A team adds a plugin that rewrites one response “just for the mobile app”, another adds a discount check because the gateway already sees every order, and a year later every release of every team waits for a gateway deploy owned by nobody, and every change risks every API at once.
A useful test: the gateway may do what is the same for every API, such as who is calling, how often and how big the request is. What depends on one domain’s rules, whether this order can still be cancelled or this user may see this document, belongs to the service that owns those rules.
When different clients need different shapes of the same data, the backends for frontends pattern gives each client type, such as the web app and the mobile app, its own thin backend, owned and released by that client’s team. It may aggregate calls and shape responses for its client, while shared concerns such as authorisation and monitoring stay in the gateway. Its costs are more services to run, an extra hop and some duplicated code between the backends (Azure Architecture Center).
CORS Tester Test the CORS headers of an API, one of the cross-origin rules a gateway often answers for its services.Interview questions
Warm-up (fresher to mid level): how does a forward proxy differ from a reverse proxy? A forward proxy acts for clients. The clients are configured to send their requests through it, and it reaches any server on their behalf, so an organisation uses it to control and log outbound traffic, filter destinations or share a cache. A reverse proxy acts for servers. Clients connect to it as if it were the server, and it forwards each request to one of the servers behind it, typically ending TLS, routing, balancing load and caching, and hiding how many servers there are. The quickest test is who chose it: the client chooses a forward proxy, and the site’s owner puts a reverse proxy in front of its servers. An API gateway is a reverse proxy with API work added, such as authentication and rate limits.
Key takeaways
- A forward proxy is chosen by clients and works for them; a reverse proxy or gateway is chosen by the site and works for its servers; an API gateway is a reverse proxy with API-specific checks.
- Offload what is the same for every service: TLS, routing, authentication, rate limits, limits and timeouts, compression, caching and tracing headers.
- A proxy removes hop-by-hop headers, adds
Via, and records the client inForwardedorX-Forwarded-For; trust only the entries your own proxies wrote. - Order the filters so that identity is proven before anything is keyed by it: in the example, the wrong order halved a real tenant’s successful requests.
- Keep business rules in the services; use a backend for each frontend when clients need differently shaped APIs.
Exercise
Exercise · Easy · JavaScript
Route requests by the longest matching path prefix
A gateway sends each request to a service by the start of its path. Write route(routes, target) in route.mjs and export it.
routes is a list such as [{ prefix: '/api/orders', service: 'orders' }, { prefix: '/', service: 'web' }], in any order. target is the request target: a path, perhaps followed by a query string, such as '/api/orders/42?expand=items'. Return { service, path }, where path is what the service receives:
- a prefix matches only at a segment boundary: /api/orders matches /api/orders and /api/orders/42, but not /api/orders-export; - the prefix / matches every path; - when several prefixes match, the longest wins; - the matched prefix is removed from the path (/api/orders/42 becomes /42, and /api/orders becomes /), except for the prefix /, which leaves the path as it is; - a query string is kept unchanged: '/api/orders/42?expand=items' becomes '/42?expand=items'.
When no prefix matches, return null, so that the gateway can answer 404 itself.
The sample tests import route from route.mjs and run in your browser.
Starter code · route.mjs
/** The service for `target` by the longest matching path prefix, and the path it receives, or null. */
export function route(routes, target) {
// Replace this line with your code.
return null;
} The sample tests · route.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { route } from './route.mjs';
const ROUTES = [
{ prefix: '/', service: 'web' },
{ prefix: '/api/orders', service: 'orders' },
{ prefix: '/api', service: 'api' },
];
test('the longest matching prefix wins, and it is removed from the path', () => {
assert.deepEqual(route(ROUTES, '/api/orders/42'), { service: 'orders', path: '/42' });
assert.deepEqual(route(ROUTES, '/api/users/7'), { service: 'api', path: '/users/7' });
});
test('a prefix matches only at a segment boundary', () => {
assert.deepEqual(route(ROUTES, '/api/orders-export'), { service: 'api', path: '/orders-export' });
assert.deepEqual(route(ROUTES, '/apis'), { service: 'web', path: '/apis' });
});
test('an exact match leaves the path /', () => {
assert.deepEqual(route(ROUTES, '/api/orders'), { service: 'orders', path: '/' });
});
test('the prefix / keeps the whole path, and a query string is kept', () => {
assert.deepEqual(route(ROUTES, '/help?lang=hi'), { service: 'web', path: '/help?lang=hi' });
assert.deepEqual(route(ROUTES, '/api/orders/42?expand=items'), { service: 'orders', path: '/42?expand=items' });
});
test('no matching prefix gives null', () => {
assert.equal(route([{ prefix: '/api', service: 'api' }], '/static/app.js'), null);
}); A hint
Split the target at the first ? into the path and the query (keep the ?). A prefix p fits the path when p is '/', when the path equals p, or when the path starts with p + '/'. Keep the fitting route with the longest prefix, then cut that prefix off the path, use '/' if nothing is left, and put the query back.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- RFC 9110: HTTP Semantics (section 3.7, Intermediaries; section 7.6, Message Forwarding) (IETF)
- RFC 7239: Forwarded HTTP Extension (IETF)
- Gateway Offloading pattern (Azure Architecture Center) (Microsoft)
- Gateway Routing pattern (Azure Architecture Center) (Microsoft)
- Backends for Frontends pattern (Azure Architecture Center) (Microsoft)
- Module ngx_http_proxy_module (nginx documentation) (nginx)
- HTTP load balancing (nginx documentation) (nginx)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress