Skip to content

Behind a proxy

This page covers what changes when the application runs behind a reverse proxy or a load balancer: where the client’s address comes from, how to rate limit by it, and what HTTPS that ends at the proxy means for cookies and security headers.

ctx.server.requestIP(ctx.req) is the address at the other end of the connection. Behind a proxy, that is the proxy, for every request.

The proxy passes the client’s address in x-forwarded-for. Each proxy on the way appends the address it received the request from:

x-forwarded-for: <what the client sent>, <client>, <proxy 1>

The client can put anything at the start of the header, so only the entries your own proxies appended can be trusted. They are at the end: the client’s address is the Nth entry from the end, where N is the number of your proxies in front of the server.

Tetsu does not parse this header, because N is a fact about your deployment, and a wrong default fails silently: count one proxy too few and every client shares a proxy’s address; take the first entry and every client picks its own.

Write N once, in a hook that adds the address to the context:

const
const trustedHops: 1
trustedHops
= 1;
export const clientIp = hook.beforeParse((ctx) => {
const
const chain: string | null
chain
= ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
headers: Headers

The headers read-only property of the with the request.

MDN Reference

headers
.get("x-forwarded-for");
if (!
const chain: string | null
chain
) {
return {
clientIp: string | undefined
clientIp
: ctx.
server: Bun.Server<unknown>

The server handling this request — the real Bun.Server, whole and unguarded, like req an escape hatch to the platform.

The way to reach connection- and server-level facts a Request does not carry: ctx.server.requestIP(ctx.req) for rate limiting by address, ctx.server.timeout(ctx.req, seconds) for a per-request idle timeout.

An address is in the form the socket reports it: a server listening on both stacks — Bun's default — reports an IPv4 client as ::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.

Deliberately not a facade: every hook is code the application author wrote or vetted, and hiding stop/reload from in-process code protects nothing. They are still process-level operations with no business inside a request — calling ctx.server.stop() from a hook takes the whole listener down.

upgrade is for the endpoints ws() declares, which the application upgrades itself. A socket upgraded by hand reaches the application's websocket handler with no endpoint on it: the connection drops, and each of its events is reported as a failure.

server
.requestIP(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
)?.
address: string | undefined

The IP address of the client.

address
};
}
return {
clientIp: string | undefined
clientIp
:
const chain: string
chain
.split(",").at(-
const trustedHops: 1
trustedHops
)?.trim() };
});

With one load balancer, trustedHops is 1. With a CDN in front of the load balancer, it is 2. Without the header, as on a developer’s machine, the connection’s address is the client’s.

This holds only when every request passes through all your proxies. Keep the server reachable only from the last one, with a private network or a firewall rule. If a proxy overwrites a header of its own, such as x-real-ip in a common nginx setup, you can read that instead, on the same condition.

Mount the hook first in the application’s beforeParse:

createApp({
hooks: { beforeParse: [clientIp] },
reportError: ({
source: FailureSource
source
,
error: unknown

What was thrown, exactly as it was thrown: not formatted, not truncated, so a logger's redaction sees the fields it knows.

error
, ctx }) =>
const logger: {
error(fields: object, message: string): void;
}
logger
.error({
err: unknown
err
:
error: unknown

What was thrown, exactly as it was thrown: not formatted, not truncated, so a logger's redaction sees the fields it knows.

error
,
source: FailureSource
source
,
clientIp: string | undefined
clientIp
: ctx?.
clientIp?: string | undefined
clientIp
}, "tetsu"),
routes: object

The topology: a group, a controller, or an array of either.

routes
,
});

The application’s later hooks see ctx.clientIp typed, and so does reportError, where it is optional because a failure may come before the hook ran. A route handler that reads it mounts the hook on the route, or declares Requires<{ clientIp: string | undefined }> — see Context and its types.

requestId() ignores an incoming x-request-id by default, since a client could stamp another client’s log lines. If your proxy sets the header on every request, requestId({ trustIncoming: true }) keeps the proxy’s id, so its logs and the application’s share it. See @tetsujs/request-id.

Bun.serve without a hostname listens on IPv4 and IPv6 at once, and reports an IPv4 client as ::ffff:203.0.113.7, not 203.0.113.7.

As a rate limit key, that is harmless. Compared with an address written the usual way, such as a proxy’s address in a list, it never matches, and nothing reports it. Strip the prefix before comparing.

A stricter address hook needs this. It trusts x-forwarded-for only on a connection from one of your proxies, and takes the connection’s address otherwise, which also covers a request that bypassed them:

const
const proxies: Set<string>
proxies
= new Set(["10.0.0.2", "10.0.0.3"]);
export const clientIp = hook.beforeParse((ctx) => {
const
const peer: string | undefined
peer
= ctx.
server: Bun.Server<unknown>

The server handling this request — the real Bun.Server, whole and unguarded, like req an escape hatch to the platform.

The way to reach connection- and server-level facts a Request does not carry: ctx.server.requestIP(ctx.req) for rate limiting by address, ctx.server.timeout(ctx.req, seconds) for a per-request idle timeout.

An address is in the form the socket reports it: a server listening on both stacks — Bun's default — reports an IPv4 client as ::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.

Deliberately not a facade: every hook is code the application author wrote or vetted, and hiding stop/reload from in-process code protects nothing. They are still process-level operations with no business inside a request — calling ctx.server.stop() from a hook takes the whole listener down.

upgrade is for the endpoints ws() declares, which the application upgrades itself. A socket upgraded by hand reaches the application's websocket handler with no endpoint on it: the connection drops, and each of its events is reported as a failure.

server
.requestIP(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
)?.
address: string

The IP address of the client.

address
.replace(/^::ffff:/, "");
const
const chain: string | null
chain
= ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
headers: Headers

The headers read-only property of the with the request.

MDN Reference

headers
.get("x-forwarded-for");
if (!
const chain: string | null
chain
||
const peer: string | undefined
peer
=== undefined || !
const proxies: Set<string>
proxies
.has(
const peer: string
peer
)) {
return {
clientIp: string | undefined
clientIp
:
const peer: string | undefined
peer
};
}
return {
clientIp: string | undefined
clientIp
:
const chain: string
chain
.split(",").at(-1)?.trim() };
});

Without the replace, proxies.has(peer) is false for every IPv4 request, and the hook reports the proxy as the client. at(-1) assumes one proxy, as trustedHops = 1 does above.

The other way out is hostname: "0.0.0.0", which listens on IPv4 only and turns IPv6 clients away. Tests have the same choice — see Testing.

rateLimit() has no default key, because behind a balancer the connection’s address is the balancer’s, and every client would share one bucket; see Choosing a key. Count by the address the hook worked out. The limiter declares the field with Requires, and the compiler checks that it is mounted after the hook:

import type { Requires } from "@tetsujs/core";
import { rateLimit } from "@tetsujs/rate-limit";
const limit = rateLimit({
limit: number

Hits allowed per window: an integer, 0 included — a limiter that refuses everything.

limit
: 100,
windowMs: number

Length of the window in milliseconds: a positive, finite number.

Anything else is refused when the limiter is made. NaN — what Number(process.env.RATE_WINDOW) reads as when the variable is not set — and 0 used to start a new window on every request, so nothing was ever refused while the headers went on reporting a budget.

windowMs
: 60_000,
key: (ctx: Requires<{
clientIp: string | undefined;
}>) => string | undefined
key
: (
ctx: Requires<{
clientIp: string | undefined;
}>
ctx
: Requires<{
clientIp: string | undefined
clientIp
: string | undefined }>) =>
ctx: Requires<{
clientIp: string | undefined;
}>
ctx
.
clientIp: string | undefined
clientIp
,
});
createApp({ hooks: { beforeParse: [clientIp, limit] },
routes: object

The topology: a group, a controller, or an array of either.

routes
});

A key of undefined skips the limit for that request. With the first hook, that happens when the chain is shorter than trustedHops, which means a request bypassed a proxy.

Several instances each count in their own memory by default, so a client gets the limit once per instance. A shared store, such as Redis, makes it one budget.

A proxy usually terminates TLS: the client talks HTTPS to it, and it talks plain HTTP to the application. Two things follow.

ctx.req.url says http:, and its host is whatever the proxy put in host. Build absolute URLs the application sends out, such as links in emails, from a public origin in your configuration, not from the request. x-forwarded-proto reports the client’s scheme, if your proxy sets it.

secure cookies still work. secure: true tells the browser to send the cookie over HTTPS only, and the browser’s connection to the proxy is HTTPS. Set it the same way with or without a proxy.

Bun can also terminate TLS itself with the tls option of Bun.serve.

secureHeaders() from @tetsujs/secure-headers sends strict-transport-security on every response, which tells browsers to use HTTPS for the site from then on, for 180 days by default. Browsers ignore it on plain HTTP responses, so the application does not need to know whether TLS ended in front of it.

import { secureHeaders } from "@tetsujs/secure-headers";
const secure = secureHeaders({
hsts?: false | HstsOptions | undefined

strict-transport-security, or false to leave it off.

Sent on every response, including those that arrived over plain HTTP, where the browser is required to ignore it — so this needs to know nothing about proxies or protocols to be correct.

hsts
: {
maxAge?: number | undefined

Lifetime in seconds. 180 days by default.

Long enough to matter and short enough to be wrong about: the browser refuses plain HTTP to this host until it expires, and there is no way to reach the browsers that already heard it.

maxAge
: 63_072_000,
includeSubDomains?: boolean | undefined

Whether every subdomain is covered too. Off by default.

A subdomain served over HTTP — a legacy box, a status page, a certificate-less internal tool — stops being reachable the moment one request to the parent carries this, for as long as maxAge says. It is the right setting for most deployments and the wrong default for any of them.

includeSubDomains
: true } });
createApp({ hooks: { beforeResponse: [secure] },
routes: object

The topology: a group, a controller, or an array of either.

routes
});

includeSubDomains and preload are off by default: the first breaks any subdomain still on plain HTTP, and the second takes months to undo. See @tetsujs/secure-headers.