HTTP Strict Transport Security is one of those headers you set once, then forget about until you realize your rollout plan was sloppy.

If you run a Dart app with Shelf, HSTS is straightforward: send a Strict-Transport-Security response header on HTTPS responses, and don’t break local development while doing it.

This guide is the practical version: what to send, when to send it, and copy-paste Shelf middleware you can actually use.

What HSTS does

HSTS tells browsers:

  • only talk to this site over HTTPS
  • automatically rewrite future http:// requests to https://
  • optionally apply the rule to subdomains
  • optionally make the domain eligible for browser preload lists

Typical header:

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

That means:

  • max-age=31536000: remember the rule for 1 year
  • includeSubDomains: apply it to all subdomains too

If you also add preload:

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

you’re signaling that your domain is intended for browser preload programs. Don’t add preload casually. It’s sticky and easy to regret if your subdomains aren’t all HTTPS-ready.

The one rule people get wrong

Only send HSTS over HTTPS.

Browsers ignore HSTS headers received over plain HTTP, by design. So if your Shelf app serves both HTTP and HTTPS directly, only attach the header to secure requests.

If TLS terminates at a reverse proxy or load balancer, your Shelf app may only see plain HTTP internally. In that case, you need to trust proxy headers carefully and decide HTTPS status from forwarded metadata.

A safe default policy

For most production apps, I’d start here:

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

That gives you a year of HTTPS enforcement across the whole domain tree without jumping straight into preload.

For a cautious rollout, use something shorter first:

Strict-Transport-Security: max-age=300

Then move to:

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

That gradual ramp is boring, which is exactly why it works.

Basic Shelf middleware

Here’s a reusable middleware that adds HSTS when a request is considered secure.

import 'package:shelf/shelf.dart';

Middleware hsts({
  Duration maxAge = const Duration(days: 365),
  bool includeSubDomains = true,
  bool preload = false,
  bool Function(Request request)? isSecureRequest,
}) {
  final maxAgeSeconds = maxAge.inSeconds;

  String headerValue = 'max-age=$maxAgeSeconds';
  if (includeSubDomains) {
    headerValue += '; includeSubDomains';
  }
  if (preload) {
    headerValue += '; preload';
  }

  bool defaultIsSecure(Request request) {
    return request.requestedUri.scheme == 'https';
  }

  final secureCheck = isSecureRequest ?? defaultIsSecure;

  return (Handler innerHandler) {
    return (Request request) async {
      final response = await innerHandler(request);

      if (!secureCheck(request)) {
        return response;
      }

      return response.change(
        headers: {
          ...response.headers,
          'strict-transport-security': headerValue,
        },
      );
    };
  };
}

Usage:

import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;

void main() async {
  final handler = Pipeline()
      .addMiddleware(logRequests())
      .addMiddleware(hsts())
      .addHandler((request) {
        return Response.ok('Hello from Shelf');
      });

  final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8443);
  print('Serving on https://${server.address.host}:${server.port}');
}

If your app is actually listening with TLS, this works fine.

HSTS behind a reverse proxy

This is the setup most teams actually run.

Nginx, Caddy, Envoy, a cloud load balancer, or Kubernetes ingress handles TLS. Your Shelf app receives proxied HTTP. If you rely only on request.requestedUri.scheme, you may incorrectly think the request is insecure and skip HSTS.

You need a secure request check based on trusted proxy headers.

A common approach:

bool isSecureFromProxy(Request request) {
  final forwardedProto = request.headers['x-forwarded-proto'];
  if (forwardedProto != null) {
    return forwardedProto.toLowerCase().split(',').first.trim() == 'https';
  }

  final forwardedSsl = request.headers['x-forwarded-ssl'];
  if (forwardedSsl != null) {
    return forwardedSsl.toLowerCase() == 'on';
  }

  return request.requestedUri.scheme == 'https';
}

Then:

final handler = Pipeline()
    .addMiddleware(hsts(
      maxAge: const Duration(days: 365),
      includeSubDomains: true,
      isSecureRequest: isSecureFromProxy,
    ))
    .addHandler((request) => Response.ok('ok'));

Don’t trust proxy headers from the public internet

This matters. If your Shelf app is directly reachable, a client can send fake X-Forwarded-Proto: https headers and trick your app into behaving as if the connection was secure.

The fix is architectural:

  • only allow traffic to Shelf from your trusted proxy/load balancer
  • strip and re-add forwarding headers at the proxy
  • don’t expose the app directly if your security logic depends on proxy headers

If your proxy can set HSTS itself, that’s often cleaner. But app-level middleware is still useful when you want the policy defined in code and tested with the app.

Local development and localhost

Don’t force HSTS on localhost unless you know exactly why.

Browsers treat HSTS as a persistent client-side rule. If you set it on a dev hostname and later switch between HTTP and HTTPS, you can get annoying redirect behavior and certificate errors.

A practical pattern is to skip HSTS for local hosts:

bool isLocalHost(String host) {
  return host == 'localhost' ||
      host == '127.0.0.1' ||
      host == '::1';
}

Middleware hstsForProduction({
  Duration maxAge = const Duration(days: 365),
  bool includeSubDomains = true,
  bool preload = false,
}) {
  return hsts(
    maxAge: maxAge,
    includeSubDomains: includeSubDomains,
    preload: preload,
    isSecureRequest: (request) {
      if (isLocalHost(request.requestedUri.host)) {
        return false;
      }

      final forwardedProto = request.headers['x-forwarded-proto'];
      if (forwardedProto != null) {
        return forwardedProto.toLowerCase().split(',').first.trim() == 'https';
      }

      return request.requestedUri.scheme == 'https';
    },
  );
}

Usage:

final handler = Pipeline()
    .addMiddleware(hstsForProduction())
    .addHandler((request) => Response.ok('ok'));

That keeps local development sane.

Preload: only when you’re really ready

If you want preload, your policy must generally look like this:

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

Before you do that, make sure:

  • your apex domain supports HTTPS
  • www supports HTTPS if you use it
  • all subdomains you care about support HTTPS
  • you actually want includeSubDomains
  • you can keep this commitment long term

Preload is not a cute optimization. It’s a hard commitment. I’ve seen teams break old subdomains and internal tools because someone added preload without inventorying the domain space first.

If you’re not sure, skip it.

How to disable HSTS

To tell browsers to forget HSTS for a host, send:

Strict-Transport-Security: max-age=0

In Shelf:

Middleware disableHsts() {
  return (Handler innerHandler) {
    return (Request request) async {
      final response = await innerHandler(request);
      return response.change(
        headers: {
          ...response.headers,
          'strict-transport-security': 'max-age=0',
        },
      );
    };
  };
}

A few caveats:

  • browsers only process this over HTTPS
  • if your domain is preloaded, max-age=0 won’t magically remove it from browser preload lists
  • cached browser behavior may linger until clients receive the updated header

For most Shelf apps behind a proxy, I’d use this:

import 'package:shelf/shelf.dart';

bool isTrustedHttps(Request request) {
  final host = request.requestedUri.host;
  if (host == 'localhost' || host == '127.0.0.1' || host == '::1') {
    return false;
  }

  final forwardedProto = request.headers['x-forwarded-proto'];
  if (forwardedProto != null) {
    return forwardedProto.toLowerCase().split(',').first.trim() == 'https';
  }

  return request.requestedUri.scheme == 'https';
}

Middleware productionHsts() {
  return hsts(
    maxAge: const Duration(days: 365),
    includeSubDomains: true,
    preload: false,
    isSecureRequest: isTrustedHttps,
  );
}

Then wire it in:

final handler = Pipeline()
    .addMiddleware(logRequests())
    .addMiddleware(productionHsts())
    .addHandler((request) {
      return Response.ok(
        'secure',
        headers: {'content-type': 'text/plain'},
      );
    });

What to verify after deployment

Check these:

  1. HTTPS responses include Strict-Transport-Security
  2. HTTP responses do not try to set it
  3. proxy headers are trusted only from your proxy
  4. local development hosts are excluded if needed
  5. includeSubDomains won’t break forgotten subdomains

You can inspect headers with curl:

curl -I https://your-domain.example

Expected output should include something like:

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

If you want a quick check of your full header set, run a scan with Headertest.

Common mistakes

Sending HSTS on HTTP only

Does nothing. Browsers ignore it.

Using includeSubDomains too early

This can break admin panels, old APIs, staging hosts, and weird forgotten DNS entries.

Adding preload because it “sounds more secure”

That’s how teams lock themselves into a domain policy they didn’t fully understand.

Trusting X-Forwarded-Proto from anyone

If the app is internet-facing, that’s a security footgun.

Enabling HSTS in dev on reusable hostnames

Enjoy debugging browser cache behavior for the next hour.

Minimal copy-paste version

If you just want the shortest useful middleware, use this and move on:

import 'package:shelf/shelf.dart';

Middleware hstsMiddleware() {
  return (Handler innerHandler) {
    return (Request request) async {
      final response = await innerHandler(request);

      final host = request.requestedUri.host;
      final isLocal = host == 'localhost' || host == '127.0.0.1' || host == '::1';

      final forwardedProto = request.headers['x-forwarded-proto'];
      final isHttps = forwardedProto != null
          ? forwardedProto.toLowerCase().split(',').first.trim() == 'https'
          : request.requestedUri.scheme == 'https';

      if (isLocal || !isHttps) {
        return response;
      }

      return response.change(
        headers: {
          ...response.headers,
          'strict-transport-security': 'max-age=31536000; includeSubDomains',
        },
      );
    };
  };
}

That’s enough for a lot of real apps.

If you want my opinion: start with one year plus includeSubDomains, skip preload until you’ve audited your domain properly, and be very deliberate about proxy trust. HSTS is simple technically, but the blast radius is always operational.