Operations
Reverse proxy
Run the API behind a reverse proxy (TLS, forwarded headers, cookies) in production.
On this page
In production the API runs plain HTTP. docker-compose.prod.yml binds it with
ASPNETCORE_URLS=http://+:8080 and expects a TLS-terminating reverse proxy (nginx, Caddy, Traefik,
an ALB, an ingress controller) in front of it. The SPA is served as static assets and does not care
about any of this; see Deployment for the build.
Without help, every request behind the proxy looks like it came from the proxy itself over HTTP: the wrong client IP and the wrong scheme. Three things break as a result:
- HTTPS redirection loops, because
Request.Schemeis alwayshttp. - Rate limiting buckets every caller under the proxy’s single IP.
- Auditing records the proxy’s address instead of the client’s.
- Secure cookies are dropped, because the request looks insecure.
Forwarded headers
The fix is forwarded-headers middleware. app.UseForwardedHeaders() runs first in
api/src/Slicekit.Api/Program.cs, before app.UseHttpsRedirection() and everything else, and rewrites
HttpContext.Connection.RemoteIpAddress and Request.Scheme from the X-Forwarded-For and
X-Forwarded-Proto headers the proxy sets:
app.UseForwardedHeaders();
// ...
app.UseHttpsRedirection();
The options are configured in api/src/Slicekit.Api/Configuration/Api.cs. Only X-Forwarded-For and
X-Forwarded-Proto are processed:
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
var knownProxies = builder.Configuration.GetSection("ForwardedHeaders:KnownProxies").Get<string[]>() ?? [];
var knownNetworks = builder.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() ?? [];
// ...
});
Trusting the right hops
Trust is controlled by two optional config arrays:
| Key | Meaning |
|---|---|
ForwardedHeaders:KnownProxies |
Exact proxy IPs to trust (e.g. 10.0.0.7) |
ForwardedHeaders:KnownNetworks |
Proxy-only CIDR ranges to trust (e.g. 10.0.0.0/28) |
Both arrays default to empty. When both are empty, ASP.NET Core’s loopback defaults
(127.0.0.0/8 and ::1) stay in place. Forwarded headers from other immediate peers are ignored,
including X-Forwarded-Proto. Configure your proxy’s IP as seen by the API even for a single proxy.
Populating either array replaces both framework trust lists. Only the configured addresses and
networks are trusted, so include loopback explicitly if local callers need forwarded headers. Prefer
exact IPs or a narrow subnet containing only trusted proxies; do not trust an entire shared Docker
network. Override the arrays with environment variables, using the __ separator and a __0, __1
index for entries (see Configuration):
ForwardedHeaders__KnownProxies__0=10.0.0.7
ForwardedHeaders__KnownProxies__1=127.0.0.1
ForwardedHeaders__KnownProxies__2=::1
Replace 10.0.0.7 with the actual proxy peer IP. For a proxy-only subnet, use
ForwardedHeaders__KnownNetworks__0 with its CIDR instead. In production Compose, add the entries
to api.environment in docker-compose.prod.yml; setting .env values alone does not pass them
to the API. Keep both loopback entries for the localhost readiness probe, which sends
X-Forwarded-Proto: https. With both arrays empty, the probe uses the framework defaults.
Chained proxies
The default ForwardLimit is 1: one forwarded hop is consumed. If two proxies sit in front of
the API (a CDN in front of an ingress, say), raise it so the real client IP at the far end is read:
options.ForwardLimit = 2;
Set it to exactly the number of trusted hops, never higher. Each extra hop is one more
X-Forwarded-For entry a client could forge.
TLS termination
Terminate TLS at the proxy and forward plain HTTP to the API on port 8080. The proxy is responsible
for the certificate, HTTP-to-HTTPS redirects at the edge, and setting X-Forwarded-Proto: https so
the API knows the original request was secure.
Cookies and CSRF
This matters most for authentication. The auth cookies in api/src/Slicekit.Api/Auth/CookieHelper.cs
are issued with Secure = true outside development and SameSite=Strict, and the session cookie in
Configuration/Auth.cs uses CookieSecurePolicy.Always in production. A browser will only return a
Secure cookie over HTTPS, and the SPA and API share an origin, so the SameSite policy is the CSRF
defence rather than a header check.
Forwarded headers are what make this work behind the proxy. With X-Forwarded-Proto: https
propagated and trusted, the request is treated as secure, cookies are accepted, and HTTPS redirection
does not loop. Get the forwarded headers wrong and login silently fails: the cookie is set but the
browser refuses to store it. See Authentication for the cookie and CSRF
model.
Sample nginx config
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/ssl/certs/app.example.com.pem;
ssl_certificate_key /etc/ssl/private/app.example.com.key;
location / {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
$proxy_add_x_forwarded_for appends the client to any existing chain, and $scheme is https here.
Configure the API to trust nginx’s peer IP so it accepts the client IP and original scheme.
Checklist
- Proxy terminates TLS and forwards plain HTTP to the API on
8080. - Proxy sets
X-Forwarded-ForandX-Forwarded-Protoon every request. - A non-loopback proxy: configure its peer IP or proxy-only CIDR in
KnownProxiesorKnownNetworks. - Explicit entries replace both defaults: include
127.0.0.1and::1for the localhost readiness probe. - Send forged forwarded headers from an untrusted peer and confirm they are ignored.
- More than one hop: set
ForwardLimitto the exact number of trusted hops. - Log in over HTTPS and confirm the auth cookies are stored, not dropped.
- Hit an audited endpoint and confirm the recorded IP is the client’s, not the proxy’s.
- Exhaust an IP-partitioned rate limit from one client and confirm a second client is not throttled.