Three ways in, and no others

A cozy hillside cottage with exactly three welcoming doors set into its front, each glowing with warm light, and a small friendly creature checking arrivals at each one while the blank wall beside them offers no other way in
My backend is that little cottage: there are exactly three doors, each watched, and the wall has no secret fourth way in. Every request either comes through a door I named, or it does not come in at all.

My bedtime-story backend serves exactly one household. My kid’s stories, my Google login, my LLM bill. So you’d think transport security could be “just put it behind HTTPS and move on.”

I didn’t want to trust that.

Infrastructure is configured by hand, per deployment, and it fails silently when it’s wrong. A misconfigured proxy, a firewall rule that didn’t apply, a dev flag I forgot to unset: any of those and the app happily answers cleartext on a real interface, and nothing tells me. So the app itself refuses a plaintext HTTP request, at the application layer, before any route runs, unless the deployment can prove the link was encrypted upstream.

That proof comes in exactly three shapes, and nothing else gets in. This is a post about why the gate lives in the app at all, what each of the three modes actually checks, and the one genuinely interesting trade-off underneath: when to pin a certificate, and when to fall back to normal CA validation.

Why the app, and not just the infra

The usual argument is that TLS is the network’s job. Terminate it at the load balancer, let the app speak plaintext on the trusted internal hop. That’s fine when you have a hardened, single, well-understood ingress path.

My app has three. Tailscale, direct HTTPS, and a reverse proxy, toggled with env vars per deployment. Three paths means three chances to misconfigure, and the failure mode of getting it wrong is invisible: the app answers cleartext and never complains.

So the gate is defense in depth. The rule I want is simple, and I want it asserted in code where I can read it: a plaintext request is rejected unless something in this deployment explicitly vouches for the encryption. If I misconfigure the infra, the app fails closed (HTTP 400 tls_required) instead of failing open. Same instinct as a moderation filter failing closed. I’d rather the app refuse to work than quietly do the unsafe thing.

It’s wired as the very first middleware, right after security headers and before any controller:

// src/main.ts (abbreviated)
app.use(securityHeaders());
app.use(tlsGate(cfg));       // <-- runs before every route
app.enableShutdownHooks();
await app.listen(cfg.appPort);

cfg carries trustTailscale, trustProxy, trustLocalhost, and an optional proxySecret, all parsed from env in one place (src/common/config.ts) so no code anywhere else reads process.env and second-guesses the policy.

The gate, in full

Here’s the core middleware (src/common/tls-gate.ts), lightly abbreviated:

export function tlsGate(opts: {
  trustTailscale: boolean;
  trustProxy: boolean;
  trustLocalhost?: boolean;
  proxySecret?: string | null;
}) {
  return (req: any, res: any, next: () => void) => {
    if (req.path === '/healthz') return next();

    // Only trust X-Forwarded-Proto if the proxy secret matches (when set),
    // so a direct caller that bypasses the proxy can't spoof the header.
    const proxySecretOk =
      !opts.proxySecret || req.headers['x-proxy-secret'] === opts.proxySecret;
    const viaProxy =
      opts.trustProxy && proxySecretOk &&
      req.headers['x-forwarded-proto'] === 'https';

    const fromLoopback =
      !!opts.trustLocalhost && isLoopback(req.socket?.remoteAddress);

    if (req.protocol === 'http'
        && !opts.trustTailscale && !viaProxy && !fromLoopback) {
      res.status(400).json({ error: 'tls_required' });
      return;
    }
    next();
  };
}

function isLoopback(addr?: string): boolean {
  return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
}

Read the if slowly. A request that arrived as plaintext http is rejected unless at least one trust condition holds. HTTPS requests always pass, because they’re already encrypted. Everything hinges on those three booleans. Let’s take them one at a time.

Trust mode 1: the Tailscale mesh

When the backend runs on a Tailscale node and is only reachable over the tailnet, the transport is already encrypted by WireGuard before it ever reaches the app. There’s no leaf certificate for the app to reason about. The mesh is the encryption, and the mesh is the authentication too: only enrolled devices can even open the socket.

So in this mode the gate simply trusts the link. trustTailscale short-circuits the plaintext rejection, and the app speaks plain HTTP on the tailnet interface because there is no untrusted network segment between client and server. This is the mode I run when it’s just my own devices talking to the box.

The load-bearing assumption: the box is not also exposed on a public interface. If it were, TRUST_TAILSCALE=true would be a hole, because you’d be trusting a flag instead of the topology. Tailscale mode is correct precisely because the tailnet is the only way in.

Trust mode 2: direct HTTPS with a pinned certificate

This is the interesting one, because the enforcement is split across both ends.

Server side, the backend actually terminates TLS itself. main.ts reads a key/cert pair when cfg.tls is set:

const httpsOptions = cfg.tls
  ? { key: fs.readFileSync(cfg.tls.key), cert: fs.readFileSync(cfg.tls.cert) }
  : undefined;
const app = await NestFactory.create<NestExpressApplication>(
  AppModule, { httpsOptions, bufferLogs: true });

Now req.protocol === 'https' for real connections, so the gate lets them through.

Client side is where the real security lives. A CA-valid certificate isn’t enough. The Flutter client checks that the server’s leaf cert is specifically the one I baked into the app. The comparison is SHA-256 of the DER-encoded certificate against a hash shipped as an asset:

// client/lib/common/api_client.dart (abbreviated)
bool certPinMatches(List<int> der, String pin) =>
    sha256.convert(der).toString() == pin;

// ...building the Dio HTTP client:
final pin = (await rootBundle.loadString(cfg.certPinAssetPath)).trim();
if (pin.isNotEmpty && pin != 'PLACEHOLDER_REPLACE_AT_BUILD_TIME') {
  (dio.httpClientAdapter as IOHttpClientAdapter).validateCertificate =
      (cert, host, port) => cert != null && certPinMatches(cert.der, pin);
}

Two details worth calling out, both of which I got wrong in an earlier draft.

First, use validateCertificate, not badCertificateCallback. The bad-cert callback only fires for certs that fail default validation. A CA-valid but unrecognized certificate (say, a mis-issued cert for your actual hostname, or a trusted corporate MITM root) would pass default validation and never reach the callback, defeating the pin entirely. validateCertificate is consulted for every connection, valid or not, but it runs after the SecurityContext and badCertificateCallback have already accepted the chain. So this is leaf pinning layered on top of normal CA validation, not a replacement for it. If you need to pin against a self-signed or otherwise non-CA-valid cert, you have to supply that trust anchor yourself (a custom SecurityContext, or a badCertificateCallback that admits the chain) so validateCertificate even gets a chance to run.

Second, the pin is a build-time asset, never fetched. It lives in client/assets/cert_pin.txt and is compiled into the binary. Fetching a pin over the network would be circular nonsense: you’d need a trusted channel to fetch the thing that establishes the trusted channel. The direct consequence is that rotating the server cert means rebuilding and re-shipping the app. That’s a real operational cost, and it’s exactly why this mode isn’t the only one.

The placeholder guard (PLACEHOLDER_REPLACE_AT_BUILD_TIME) matters. When the asset still holds the placeholder string, the client skips pinning and falls back to standard CA validation. That’s deliberate. It’s how the same build runs in Tailscale mode (no pin needed) or behind the rotating-cert proxy.

Trust mode 3: the platform reverse proxy

When the app runs behind the platform’s reverse proxy (Coolify plus Let’s Encrypt), the proxy terminates TLS and forwards a plaintext request to the app on the internal Docker network. So req.protocol is http, but that’s fine, because the encrypted hop already happened out front. The proxy tells the app so by setting X-Forwarded-Proto: https.

The naive version, “trust X-Forwarded-Proto whenever TRUST_PROXY is set,” is only safe if the app is unreachable except through the proxy, because any direct caller could set that header themselves. The platform firewall is supposed to guarantee that, but I don’t love betting security on one firewall rule. So there’s an optional belt-and-suspenders: a shared X-Proxy-Secret header.

const proxySecretOk =
  !opts.proxySecret || req.headers['x-proxy-secret'] === opts.proxySecret;
const viaProxy =
  opts.trustProxy && proxySecretOk &&
  req.headers['x-forwarded-proto'] === 'https';

If PROXY_SECRET is configured, the app trusts X-Forwarded-Proto only when the request also carries the matching secret, which only the proxy knows. A direct caller who bypasses the proxy and spoofs X-Forwarded-Proto: https can’t produce the secret, so it’s rejected. If no secret is set, behavior is unchanged and the firewall is then the sole guarantee. It’s opt-in hardening, not a required knob.

Note what the client does in this mode: it does not pin. The pin asset holds the placeholder, so Dio uses standard CA validation. That’s the whole trade-off, and it deserves its own section.

When to pin, and when not to

A small friendly creature proudly holding a single ornate key that fits exactly one door lock perfectly, standing beside a brand-new different lock that the key no longer opens
A pinned certificate is a spare key cut for one exact lock. It is wonderful security right up until someone swaps the lock, and then my beautiful key opens nothing at all. That is why I only cut it for the door whose lock I control.

Leaf pinning narrows the trust surface on top of CA validation. You’re demanding one specific certificate rather than accepting anything the entire set of roughly 150 root CAs might issue for your host, and any of those roots could issue such a cert. That extra check comes at the price of operational fragility. So why not pin everywhere?

Because a pin is only viable when the certificate is stable.

Every cert rotation invalidates the pin, and re-pinning means shipping a new app build and waiting for users to update. If the cert rotates faster than you can ship, pinning turns into a self-inflicted outage. Think of it like a spare key cut for one specific lock: wonderful security right up until someone changes the lock, and then it opens nothing.

That’s exactly the split in this app:

Transport Cert lifetime Client validation Why
Direct HTTPS (mode 2) Long-lived, I control it Pin SHA-256(cert.der) Cert is stable; pinning eliminates the entire CA trust set
Behind proxy (mode 3) Let’s Encrypt, rotates ~every 60–90 days Standard CA validation A pinned Let’s Encrypt leaf would break on every renewal

The direct-HTTPS cert is one I issue and rotate on my own schedule, so rotation equals an app rebuild, which I accept. The platform proxy’s cert is a Let’s Encrypt leaf that renews automatically and often. Pinning it would mean an app-store release every couple of months just to keep the app able to connect. Not worth it, and CA validation of a Let’s Encrypt cert is already a reasonable bar given the app also lives behind the proxy secret and the firewall.

So the rule of thumb the code encodes: pin when you own a stable cert, CA-validate when you’re behind a proxy whose leaf rotates out from under you. The placeholder-in-the-asset mechanism is what lets one binary do both, flipping the deployment mode without a code change.

The dev escape hatch, and why it can’t leak

A small friendly creature carefully holding a toy popgun pointed safely down at the wooden floor inside a snug room, calm and unworried, with a little safety latch keeping it aimed away from anyone
My dev escape hatch is a footgun with the barrel pointed firmly at the floor. Even if I forget and leave it loaded, the worst it can do is let a neighbor already inside the same room speak up. It can never reach anyone out on the street.

Local development over loopback plaintext HTTP is the sane default (the client’s AppConfig.dev points at http://localhost:3001). But I don’t want a “just allow plaintext” flag that could accidentally survive into a deployment and disable the whole gate.

The mitigation: TRUST_LOCALHOST=true accepts plaintext, but only from a loopback peer, checked against the actual socket address, something the client cannot forge, unlike a header.

const fromLoopback =
  !!opts.trustLocalhost && isLoopback(req.socket?.remoteAddress);

function isLoopback(addr?: string): boolean {
  return addr === '127.0.0.1' || addr === '::1'
      || addr === '::ffff:127.0.0.1';
}

req.socket.remoteAddress is the TCP peer address the kernel sees. Not a header, not spoofable by the request. If a packet arrived on a real network interface, remoteAddress is that interface’s peer, not 127.0.0.1, and the gate rejects it. So even if I ship a build with TRUST_LOCALHOST=true still set (I shouldn’t, and the docs say never to), the worst case is that the app accepts plaintext from processes on the same machine only. The app itself will not accept non-loopback peers (though a local proxy, tunnel, or compromised same-host process could still relay that traffic onward). A footgun with the barrel pointed at the floor.

The build config reinforces this from the other side: release builds default the client to the production HTTPS URL, so a forgotten --dart-define can’t ship a store build pointed at localhost.

The one carve-out: /healthz

There’s a single exemption: if (req.path === '/healthz') return next();. The platform’s health probe hits the container directly over the internal network, before or around the proxy, and it doesn’t carry X-Forwarded-Proto. If the gate rejected it, the platform would mark the container unhealthy and refuse to route traffic to it. The gate would take the whole app down in order to protect it. So health checks bypass the gate. /healthz returns nothing sensitive, just liveness, so exempting it costs nothing.

What I’d tell you to take from this

  • Assert transport security in the app, not just the infra. Infra config is invisible when it’s wrong. A ~30-line middleware that fails closed turns a silent misconfiguration into a loud, obvious 400.
  • Have a small, closed set of ways in, and name each one. Three trust modes, each with an explicit check I can read: mesh-encrypted, pinned-HTTPS, or proxy-vouched. No fourth path exists.
  • Trust the socket, not the header, when you can. X-Forwarded-Proto is spoofable, so it’s gated behind a proxy secret. The dev loopback check uses the real TCP peer address, which the client can’t forge, and that’s what makes the escape hatch safe to leave in the binary.
  • Pin a stable cert, CA-validate a rotating one. Pinning eliminates the entire CA trust set, but only works when rotation-equals-rebuild is acceptable. A placeholder-in-the-asset switch lets one client binary do both.
  • Design your escape hatches to fail safe. Scoped to loopback peers, the worst TRUST_LOCALHOST can do if it escapes is accept traffic from the same machine.

That said, the point isn’t that every app should enforce TLS at the application layer. For most services, terminating at the load balancer and trusting the internal hop is the right, unfussy call, and a gate like this would be ceremony. It’s that the moment your app has more than one way in, and you toggle them by hand per deployment, the infra can no longer speak for itself, so the app has to.

One rule, three ways to satisfy it, and a 400 for everything else.

← all writing