HSTS is one of those headers that’s dead simple on paper and weirdly easy to mess up in production.

If you run a TypeScript app with tRPC, you usually don’t “add HSTS to tRPC” directly. You add it at the HTTP layer that serves your tRPC endpoint: Express, Fastify, Next.js custom server, Nginx, your edge platform, or your CDN. That distinction matters because if you set it in the wrong place, your API might still be exposed over plain HTTP during redirects or on subdomains you forgot existed.

Here’s the header:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

What it does:

  • tells browsers to only use HTTPS for your site
  • prevents downgrade attacks after the browser has seen the header once
  • can optionally cover all subdomains
  • can optionally qualify your domain for browser preload lists

What it does not do:

  • it does not replace redirects from HTTP to HTTPS
  • it does not help first-time visitors until they receive the header, unless you use preload
  • it does not fix mixed content, bad TLS, or insecure cookies by itself

The HSTS rules that actually matter

A few hard rules:

  1. Only send HSTS over HTTPS Browsers ignore it over HTTP.

  2. Start small if you’re not sure max-age=300 is a safe first rollout. I’ve seen teams brick odd legacy subdomains by jumping straight to includeSubDomains.

  3. Don’t use includeSubDomains unless every subdomain is HTTPS-ready That includes forgotten junk like old-admin.example.com.

  4. Don’t use preload casually Preload is sticky. Getting onto preload lists is easy compared to backing out.

  5. Set it at the edge if you can App-level headers are fine, but if your CDN or reverse proxy can enforce it consistently, I trust that more.

Good HSTS values

Safe rollout

Strict-Transport-Security: max-age=300

Standard production

Strict-Transport-Security: max-age=31536000

Strong production for fully HTTPS domains

Strict-Transport-Security: max-age=31536000; includeSubDomains

Preload-ready

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

tRPC with Express

If your tRPC API runs on Express, set HSTS before your tRPC middleware.

Minimal manual header

import express from 'express';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './router';

const app = express();

app.use((req, res, next) => {
  if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
    res.setHeader(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains'
    );
  }
  next();
});

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext: () => ({}),
  })
);

app.listen(3000);

If you’re behind a proxy or load balancer, also enable trust proxy so req.secure works correctly:

app.set('trust proxy', 1);

Using Helmet

If you already use Helmet, this is cleaner:

import express from 'express';
import helmet from 'helmet';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './router';

const app = express();

app.set('trust proxy', 1);

app.use(
  helmet({
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: false,
    },
  })
);

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext: () => ({}),
  })
);

app.listen(3000);

I like Helmet for consistency, but I still verify the final response headers myself. Middleware config lies less often than humans, but it still lies.

tRPC with Fastify

Fastify makes this pretty painless.

import Fastify from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { appRouter } from './router';

const fastify = Fastify({
  logger: true,
});

fastify.addHook('onSend', async (request, reply, payload) => {
  if (request.protocol === 'https' || request.headers['x-forwarded-proto'] === 'https') {
    reply.header(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains'
    );
  }
  return payload;
});

await fastify.register(fastifyTRPCPlugin, {
  prefix: '/trpc',
  trpcOptions: {
    router: appRouter,
    createContext: () => ({}),
  },
});

await fastify.listen({ port: 3000, host: '0.0.0.0' });

If Fastify sits behind a proxy, enable proxy awareness:

const fastify = Fastify({
  logger: true,
  trustProxy: true,
});

Without that, protocol detection is often wrong, and people end up sending headers inconsistently.

tRPC with Next.js

For a Next.js app using tRPC, the easiest move is usually to set HSTS globally in next.config.js or next.config.ts.

next.config.ts

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=31536000; includeSubDomains',
          },
        ],
      },
    ];
  },
};

export default nextConfig;

This covers your app routes and API routes, including tRPC if it’s exposed via /api/trpc.

If you only want it on production:

import type { NextConfig } from 'next';

const isProd = process.env.NODE_ENV === 'production';

const nextConfig: NextConfig = {
  async headers() {
    if (!isProd) return [];

    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=31536000; includeSubDomains',
          },
        ],
      },
    ];
  },
};

export default nextConfig;

That said, if your app is behind Vercel, Nginx, Cloudflare, or another edge layer, I’d rather set HSTS there and keep app code focused on app logic.

Nginx in front of tRPC

If you proxy to Node, setting HSTS in Nginx is usually the best option.

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate /path/to/fullchain.pem;
    ssl_certificate_key /path/to/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

And your HTTP listener should redirect:

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

The always flag matters. Without it, some responses won’t include the header.

Cloud/load balancer setups

If TLS terminates before your TypeScript app, HSTS should usually be added there.

Common pattern:

  • CDN/load balancer terminates HTTPS
  • app receives proxied HTTP internally
  • app thinks requests are insecure unless proxy headers are trusted
  • HSTS is better enforced at the edge anyway

If you can’t set it at the edge, make sure your app trusts proxy headers correctly and only sends HSTS when the original request was HTTPS.

Local development

Don’t waste time trying to “test HSTS normally” on localhost. Browser HSTS behavior in local setups gets messy fast.

For app code, I usually disable it in local development:

const isProd = process.env.NODE_ENV === 'production';

if (isProd) {
  app.use((req, res, next) => {
    res.setHeader(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains'
    );
    next();
  });
}

You can still test headers in staging with real HTTPS, which is where it matters.

Preload: use only when you mean it

If you want preload, your header must look like this:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

That means:

  • one year max-age or more
  • includeSubDomains
  • preload
  • every subdomain must support HTTPS

I’m opinionated here: don’t add preload because it “sounds more secure.” Add it because your whole domain portfolio is clean and you want hard guarantees for first visits.

Rollout plan I’d actually use

Phase 1: short TTL

Strict-Transport-Security: max-age=300

Watch for:

  • broken subdomains
  • weird internal tools
  • staging domains accidentally sharing cookies or hostnames
  • certificate gaps

Phase 2: one month

Strict-Transport-Security: max-age=2592000

Phase 3: one year

Strict-Transport-Security: max-age=31536000

Phase 4: add includeSubDomains

Only after you’ve audited everything.

Phase 5: add preload

Only after you’re fully confident.

How to verify it

Use your browser dev tools or curl:

curl -I https://example.com

Expected output:

HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains

Also test redirects:

curl -I http://example.com

Expected:

HTTP/1.1 301 Moved Permanently
Location: https://example.com/

If you want a quick check for your full header set, run a scan at headertest.com.

Common mistakes

Sending HSTS on HTTP only

Browsers ignore it. You need HTTPS.

Forgetting subdomains

includeSubDomains can break old systems instantly.

Setting it only on /trpc

Technically possible, but not a great idea. HSTS is a site policy, not an endpoint decoration.

Using preload too early

This is the classic “security hardening” move that turns into incident response.

Relying on app code when the edge strips headers

Always verify the final response from the public endpoint.

Official docs worth checking

If you only remember one thing, make it this: for tRPC, HSTS belongs to the HTTPS-serving layer, not the RPC framework. Set it once, set it correctly, roll it out carefully, and verify the real response your users get.