Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot

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 carries office laptops to any website; a reverse proxy or gateway receives users and routes them to services.Forward proxyReverse proxyOffice laptops(they choose the proxy)Forward proxyallowed sites, logging, shared cacheAny websiteUsers anywhereReverse proxy or API gateway(the servers choose it)TLS, routing, auth, limitsOrders serviceUsers serviceevery outbound requeston the clients' behalfrequests to one public name/api/orders/api/users

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:

Routing, header rewriting and errors in a reverse proxy JavaScript · reverse_proxy.mjs
// 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

Each request shows one behaviour that a real proxy has:

  • The prefix is cut off. /api/orders/42 reaches the orders service as /42, the way nginx replaces the matched part of the path when proxy_pass names a path of its own (nginx documentation).
  • Prefixes match whole segments. /api/orders-export does 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 Connection header lists fields that describe one connection, and an intermediary must remove those fields and Connection itself before forwarding (RFC 9110). Here that removed keep-alive, the debugging header the client had listed, and Connection itself.
  • 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-For and the standard Forwarded header, which can also carry the protocol and the original host (RFC 7239), and it adds itself to Via, 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

A request passes five gateway filters in order: TLS, limits, authentication, rate limiting, routing; then it reaches the services.ClientAPI gatewayServices(business rules live here)1. End TLS2. Size and timeout limits3. Authenticate: who is calling?4. Rate limit per verified caller5. Route by path,add tracing headers401 Unauthorized429 Too Many Requestsknown callerwrong credentialwithin the limitover the limitHTTPS requestrequest + verified identity

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:

The same traffic through two filter orders JavaScript · gateway_chain.mjs
// 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

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 in Forwarded or X-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.

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.

  1. Question 1 of 6 A company wants every laptop in its offices to reach only approved websites, with each request logged. What do you put in place?

    Choose one answer.

    Show the answer to question 1

    Answer: A forward proxy that the laptops are configured to use

    The proxy has to act for the clients, on every request they send out, so it is a forward proxy chosen by the clients' configuration. A reverse proxy acts for servers and only sees requests for the sites behind it.

  2. Question 2 of 6 Where should the check of an access token's signature and expiry live, when 30 services behind one entry point all need it?

    Choose one answer.

    Show the answer to question 2

    Answer: In the API gateway, which passes the verified identity on to the services

    The check is the same for every service, so it is a cross-cutting concern a gateway can take over once. The services still decide what that identity may do, which is business logic.

  3. Question 3 of 6 A refund is allowed only within 30 days of delivery, unless the item arrived damaged. Where does that rule belong?

    Choose one answer.

    Show the answer to question 3

    Answer: In the payments or orders service that owns refunds

    It is business logic that changes with the business. In a gateway it would tie every change of the rule to a release of shared infrastructure; in the owning service it ships with the code that uses it.

  4. Question 4 of 6 A proxy receives a request with the header Connection: keep-alive, x-trace. What must it do with the X-Trace header before forwarding the request?

    Choose one answer.

    Show the answer to question 4

    Answer: Remove it, because the Connection header lists it as meant only for this hop

    HTTP requires an intermediary to remove every field named in the Connection header, and the Connection header itself, before forwarding: those fields describe one connection, not the message.

  5. Question 5 of 6 Your edge proxy appends the client's address to X-Forwarded-For. Which address can the app behind it trust as the client's?

    Choose one answer.

    Show the answer to question 5

    Answer: The entry your own edge proxy added, the last one written by a proxy you run

    A client can send any X-Forwarded-For it likes, so the entries before your own proxy's are only claims. The entry your edge proxy wrote, from the address of the connection it accepted, is the one you can rely on.

  6. Question 6 of 6 In this lesson's gateway example, why did the real tenant lose requests when the rate limit ran before authentication?

    Choose one answer.

    Show the answer to question 6

    Answer: The attacker's requests used up the tenant's bucket before authentication rejected them, because the limiter trusted the tenant name they claimed

    A limiter keyed by an unverified name lets anyone spend another caller's allowance. Authenticating first means the limiter only ever counts requests from callers whose identity is proven.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.