HTTP Strict Transport Security is one of those headers that looks trivial and still gets deployed wrong all the time.

If you run a Nim app with Jester, HSTS is easy to add. The hard part is adding it in the right place, with the right conditions, and without bricking a staging domain or forcing bad HTTPS assumptions behind a reverse proxy.

HSTS tells the browser:

  • only use HTTPS for this site
  • keep doing that for a specific amount of time
  • optionally apply the rule to subdomains
  • optionally treat the domain as preload-eligible

The header looks like this:

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

A stronger preload-ready version looks like this:

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

A browser that has seen this header over a valid HTTPS connection will stop attempting plain HTTP for that host for the duration of max-age.

That means:

  • users get protection against protocol downgrade attacks
  • first-party navigation gets upgraded to HTTPS
  • some SSL stripping attacks become much harder

It also means:

  • if you enable it too aggressively, rollback gets annoying
  • if you include subdomains without checking them, you can break old services
  • if you send it over HTTP, browsers ignore it

What HSTS does not do

People sometimes treat HSTS like a complete HTTPS solution. It is not.

HSTS does not:

  • replace proper HTTP to HTTPS redirects
  • fix a broken certificate
  • secure the first ever HTTP visit unless the domain is preloaded
  • protect non-browser clients in the same way browsers do

You still need:

  • valid TLS certificates
  • a clean redirect from HTTP to HTTPS
  • secure cookies
  • sane proxy configuration

Basic HSTS in Jester

Jester makes response headers easy. You can set Strict-Transport-Security on HTTPS responses.

Here is a minimal app:

import jester

routes:
  get "/":
    resp "Hello over HTTPS"

Now add the header:

import jester

const hstsValue = "max-age=31536000; includeSubDomains"

routes:
  before:
    resp.headers["Strict-Transport-Security"] = hstsValue

  get "/":
    resp "Hello over HTTPS"

That works, but it is still too naive for production.

Why? Because this blindly adds HSTS to every response handled by Jester, regardless of whether the original request was actually HTTPS at the edge. If your app sits behind Nginx, HAProxy, Caddy, or a load balancer terminating TLS, your Nim process may only see internal HTTP.

That is where people get sloppy.

Only send HSTS for HTTPS requests

Browsers only honor HSTS when received over HTTPS, so sending it over plain HTTP is useless. Worse, it can hide deployment mistakes because you think the header is present when it does nothing.

If you are terminating TLS directly in the Jester app, check the request scheme if available in your setup. If you are behind a reverse proxy, trust only proxy headers that your infrastructure actually sets.

A practical pattern is to inspect X-Forwarded-Proto when you control the proxy:

import jester

const hstsValue = "max-age=31536000; includeSubDomains"

proc isHttps(): bool =
  let xfProto = request.headers.getOrDefault("X-Forwarded-Proto")
  result = xfProto.toLowerAscii() == "https"

routes:
  before:
    if isHttps():
      resp.headers["Strict-Transport-Security"] = hstsValue

  get "/":
    resp "Secure response"

If your app is directly serving TLS, adapt the check to your runtime environment instead of relying on X-Forwarded-Proto.

My rule: if a proxy sets the scheme, lock down trust so only that proxy can reach the app. Never trust arbitrary client-supplied forwarding headers on a public socket.

Add HTTP to HTTPS redirect too

HSTS is not a substitute for redirects. You still want plain HTTP requests to move to HTTPS immediately.

A simple redirect handler might look like this:

import jester

proc isHttps(): bool =
  request.headers.getOrDefault("X-Forwarded-Proto").toLowerAscii() == "https"

proc redirectToHttps() =
  let host = request.headers.getOrDefault("Host")
  let target = "https://" & host & request.path
  halt Http301, {"Location": target}.newHttpHeaders()

routes:
  before:
    if not isHttps():
      redirectToHttps()

    resp.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"

  get "/":
    resp "Hello secure world"

This is enough to show the pattern, but I would usually do the HTTP-to-HTTPS redirect at the reverse proxy instead. It is faster, simpler, and keeps scheme handling out of app code.

Still, even if the proxy does the redirect, the app should emit HSTS on the HTTPS response path.

A cleaner helper for reuse

Once you have more than a toy app, put header logic in helpers.

import jester, strutils

const
  HstsMaxAge = 31536000
  HstsHeader = "max-age=" & $HstsMaxAge & "; includeSubDomains"

proc forwardedProto(): string =
  request.headers.getOrDefault("X-Forwarded-Proto").toLowerAscii()

proc isHttpsRequest(): bool =
  forwardedProto() == "https"

proc setSecurityHeaders() =
  if isHttpsRequest():
    resp.headers["Strict-Transport-Security"] = HstsHeader

routes:
  before:
    setSecurityHeaders()

  get "/":
    resp "Index"

  get "/health":
    resp "ok"

This gives you one place to adjust policy later.

Choosing the right HSTS value

There are three knobs:

  • max-age
  • includeSubDomains
  • preload

max-age

For production, I usually start with something short if I am not fully confident in the estate:

Strict-Transport-Security: max-age=300

That is five minutes. Good for validation.

Then move to a week:

Strict-Transport-Security: max-age=604800

Then a year:

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

If you want preload eligibility, use at least two years:

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

includeSubDomains

This is where people break internal junk.

If you serve:

  • app.example.com
  • api.example.com
  • old-admin.example.com
  • mta-sts.example.com
  • random forgotten subdomains from 2018

then includeSubDomains means every one of those needs to behave correctly over HTTPS.

If even one important subdomain cannot support HTTPS properly, do not turn this on yet.

preload

preload is not just another flag. It is a commitment. Browsers can ship your domain as HTTPS-only from first contact.

That is great when you are ready. It is painful if you are not.

I would only add preload when:

  • the apex domain is always HTTPS
  • www is always HTTPS if it exists
  • all subdomains that matter are HTTPS-capable
  • redirects are clean
  • certificates are managed well
  • you actually want the operational burden

Production example with environment-based policy

A nice pattern is using a shorter HSTS value outside production.

import jester, os, strutils

proc isHttpsRequest(): bool =
  request.headers.getOrDefault("X-Forwarded-Proto").toLowerAscii() == "https"

proc hstsValue(): string =
  let env = getEnv("APP_ENV", "development")

  case env
  of "production":
    result = "max-age=31536000; includeSubDomains"
  of "staging":
    result = "max-age=300"
  else:
    result = ""

proc applySecurityHeaders() =
  let hsts = hstsValue()
  if hsts.len > 0 and isHttpsRequest():
    resp.headers["Strict-Transport-Security"] = hsts

routes:
  before:
    applySecurityHeaders()

  get "/":
    resp "Hello"

I like this because staging environments are exactly where teams accidentally enable long-lived HSTS on throwaway hostnames, then wonder why browser testing gets weird for months.

Reverse proxy setup matters

If Jester is behind a proxy, the proxy should usually do three things:

  • terminate TLS
  • redirect HTTP to HTTPS
  • pass the original scheme in a trusted header

Your app then uses that header only because the proxy is trusted and the app is not directly exposed.

The failure mode I see most often is this:

  • proxy terminates HTTPS
  • app receives internal HTTP
  • app thinks requests are insecure
  • app either skips HSTS entirely or loops redirects badly

So test the whole chain, not just the app.

Check the final HTTPS response headers in the browser dev tools or with a scanner. For a quick verification, https://headertest.com is handy.

Common mistakes

1. Sending HSTS on HTTP only

Browsers ignore it. This gives you fake confidence.

2. Using includeSubDomains too early

Legacy subdomains will bite you.

3. Preloading before you are ready

Rollback is not instant. Treat preload like an infrastructure decision, not a header tweak.

4. Forgetting local development

Do not force long-lived HSTS on localhost-style workflows or disposable test domains.

5. Trusting spoofed forwarding headers

If the app is internet-facing and you trust X-Forwarded-Proto from anyone, a client can lie to you.

How to disable HSTS if you mess up

Browsers cache HSTS. To ask them to forget it, send:

Strict-Transport-Security: max-age=0

Example in Jester:

import jester

routes:
  before:
    if request.headers.getOrDefault("X-Forwarded-Proto").toLowerAscii() == "https":
      resp.headers["Strict-Transport-Security"] = "max-age=0"

  get "/":
    resp "HSTS disabled"

That only helps after the browser successfully reaches your site over HTTPS again. If you have already painted yourself into a corner with broken certs or dead subdomains, recovery gets annoying fast.

A practical default I would ship

For a normal production Jester app behind a trusted TLS proxy, I would start here:

import jester, strutils

const HstsPolicy = "max-age=31536000; includeSubDomains"

proc isHttpsRequest(): bool =
  request.headers.getOrDefault("X-Forwarded-Proto").toLowerAscii() == "https"

proc applySecurityHeaders() =
  if isHttpsRequest():
    resp.headers["Strict-Transport-Security"] = HstsPolicy

routes:
  before:
    applySecurityHeaders()

  get "/":
    resp "Home"

  get "/login":
    resp "Login"

Then I would verify:

  • HTTP redirects to HTTPS at the proxy
  • every HTTPS response includes the header
  • subdomains are actually ready for includeSubDomains
  • I am not using preload casually

HSTS is simple when your deployment is simple. Most deployments are not simple. That is why this tiny header deserves more respect than it usually gets.