If you run a Phoenix app over HTTPS and you are not sending HSTS yet, you are leaving a pretty basic downgrade attack on the table.

HSTS tells the browser: “Stop trying plain HTTP for this site. Use HTTPS only for a while.” That blocks SSL stripping and a bunch of accidental insecure requests after the first secure visit.

Phoenix makes this easy, but there are a few sharp edges:

  • local development can get annoying fast
  • preload is powerful and easy to misuse
  • reverse proxy setups can make redirects and headers behave weirdly
  • subdomains can break if you turn on the wrong flag too early

Here’s the practical version.

What HSTS does

The header looks like this:

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

Browser behavior:

  • max-age=31536000 means remember HTTPS-only for 1 year
  • includeSubDomains applies the rule to all subdomains
  • preload says you want to be included in browser preload lists

Once a browser sees this header over a valid HTTPS connection, future requests to that host get upgraded to HTTPS before the request leaves the browser.

That “before the request leaves the browser” part is the whole point.

The Phoenix way: use put_secure_browser_headers/2

Phoenix ships with Plug.SSL, which can both redirect HTTP to HTTPS and set HSTS headers.

The most common setup lives in your endpoint.

Basic HSTS setup in endpoint.ex

For a Phoenix app, start here:

# lib/my_app_web/endpoint.ex
defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head

  plug Plug.Session,
    store: :cookie,
    key: "_my_app_key",
    signing_salt: "change-me"

  plug Plug.SSL,
    rewrite_on: [:x_forwarded_proto],
    hsts: true,
    expires: 31_536_000

  plug MyAppWeb.Router
end

That gives you:

  • HTTP → HTTPS redirect
  • HSTS header with a one-year max age

If you’re behind Nginx, HAProxy, Fly, ALB, Cloudflare, or basically any proxy that terminates TLS before Phoenix, rewrite_on: [:x_forwarded_proto] matters. Without it, Phoenix may think the request is plain HTTP and redirect in circles or skip secure behavior.

What header this sends

With that config, Phoenix will send something like:

Strict-Transport-Security: max-age=31536000

That’s a solid start.

If you want to verify what your site is actually returning, use your browser dev tools, curl, or run a free security headers scan at HeaderTest.

Add includeSubDomains only when you mean it

A stricter setup:

plug Plug.SSL,
  rewrite_on: [:x_forwarded_proto],
  hsts: true,
  expires: 31_536_000,
  subdomains: true

This sends:

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

Use this only if every subdomain is HTTPS-only and stays that way.

That includes forgotten junk like:

  • old-admin.example.com
  • status.example.com
  • m.example.com
  • dev.example.com

I’ve seen teams break internal tools because they enabled includeSubDomains without auditing what existed. Browsers are obedient here. If a subdomain still needs HTTP, HSTS will make it unreachable.

Preload: only if you’re ready

Preload means browsers ship with your domain hardcoded as HTTPS-only, even before the first visit.

In Phoenix:

plug Plug.SSL,
  rewrite_on: [:x_forwarded_proto],
  hsts: true,
  expires: 63_072_000,
  subdomains: true,
  preload: true

That sends:

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

Why 63072000? Because preload submission requires at least 2 years.

Preload is great for high-confidence production domains. It is bad for domains that are still messy.

Before using preload, make sure all of these are true:

  • your apex domain redirects HTTP to HTTPS
  • every subdomain supports HTTPS
  • you want includeSubDomains
  • you can commit to HTTPS long term
  • your certificate coverage is clean

Preload is not a casual checkbox. Undoing it is slower than enabling it.

A production-safe pattern with environment config

I prefer making HSTS configurable instead of hardcoding it directly in the endpoint. That keeps staging and production sane.

config/runtime.exs

import Config

if config_env() == :prod do
  config :my_app, MyAppWeb.Endpoint,
    force_ssl: [
      rewrite_on: [:x_forwarded_proto],
      hsts: true,
      expires: String.to_integer(System.get_env("HSTS_MAX_AGE", "31536000")),
      subdomains: System.get_env("HSTS_INCLUDE_SUBDOMAINS", "false") == "true",
      preload: System.get_env("HSTS_PRELOAD", "false") == "true"
    ]
end

endpoint.ex

defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  # ...
  plug Plug.SSL, Application.compile_env(:my_app, __MODULE__)[:force_ssl] || []
  plug MyAppWeb.Router
end

That works, but there’s a gotcha: compile_env is compile-time. If you want runtime flexibility, define the options directly in config and let Phoenix consume :force_ssl normally.

A cleaner Phoenix-native version is this:

config/runtime.exs

import Config

if config_env() == :prod do
  config :my_app, MyAppWeb.Endpoint,
    force_ssl: [
      rewrite_on: [:x_forwarded_proto],
      hsts: true,
      expires: String.to_integer(System.get_env("HSTS_MAX_AGE", "31536000")),
      subdomains: System.get_env("HSTS_INCLUDE_SUBDOMAINS", "false") == "true",
      preload: System.get_env("HSTS_PRELOAD", "false") == "true"
    ]
end

endpoint.ex

defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  # Phoenix will use force_ssl from config
  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()
  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session,
    store: :cookie,
    key: "_my_app_key",
    signing_salt: "change-me"

  plug MyAppWeb.Router
end

Phoenix reads force_ssl from endpoint config, which is usually what you want.

The short version: use force_ssl in endpoint config

This is the common production config:

# config/runtime.exs
import Config

if config_env() == :prod do
  config :my_app, MyAppWeb.Endpoint,
    force_ssl: [
      rewrite_on: [:x_forwarded_proto],
      hsts: true,
      expires: 31_536_000
    ]
end

That’s enough for most apps.

Local development: do not enable HSTS for localhost

This is where people make their own life harder.

If your browser caches HSTS for a dev hostname, you can end up chasing weird redirect and certificate issues. Browsers are doing exactly what you asked.

Keep HSTS off in dev:

# config/dev.exs
import Config

config :my_app, MyAppWeb.Endpoint,
  force_ssl: false

If you test HTTPS locally with a custom hostname, be careful with long max-age values. For test environments, use a tiny value:

# config/test.exs or a dedicated staging config
import Config

config :my_app, MyAppWeb.Endpoint,
  force_ssl: [
    rewrite_on: [:x_forwarded_proto],
    hsts: true,
    expires: 300
  ]

Five minutes is forgiving. One year is not.

How to check the header

Use curl:

curl -I https://example.com

Expected output:

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

For subdomains + preload:

strict-transport-security: max-age=63072000; includeSubDomains; preload

If you do not see the header:

  • check that the request is actually HTTPS
  • check whether your proxy strips or overwrites headers
  • check Phoenix endpoint config in the running environment
  • check whether a CDN is caching an old response

Nginx or proxy termination notes

If TLS terminates at Nginx and Phoenix sits behind it, you have two options:

  • set HSTS at Phoenix
  • set HSTS at Nginx

I prefer setting it in one place only. Duplicating security headers across layers gets messy fast.

If you choose Nginx, make that explicit and do not rely on Phoenix for HSTS:

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

If you choose Phoenix, make sure forwarded proto is passed correctly:

proxy_set_header X-Forwarded-Proto $scheme;

And keep this in Phoenix:

force_ssl: [rewrite_on: [:x_forwarded_proto], hsts: true, expires: 31_536_000]

Common mistakes

Setting preload without includeSubDomains

Preload requires it. No exceptions.

Using a short max-age in production forever

If you want HSTS to actually matter, use a meaningful value. One year is a reasonable default. Two years if you’re preloading.

Enabling includeSubDomains before auditing subdomains

This is the one that breaks things.

Testing with HTTP after your browser cached HSTS

That’s not Phoenix being weird. That’s your browser honoring the policy.

Forgetting the first-visit problem

HSTS does not protect the very first visit unless your domain is preloaded. That’s why preload exists.

Good default for most Phoenix apps

config :my_app, MyAppWeb.Endpoint,
  force_ssl: [
    rewrite_on: [:x_forwarded_proto],
    hsts: true,
    expires: 31_536_000
  ]

Mature setup for HTTPS-only domains with safe subdomains

config :my_app, MyAppWeb.Endpoint,
  force_ssl: [
    rewrite_on: [:x_forwarded_proto],
    hsts: true,
    expires: 31_536_000,
    subdomains: true
  ]

Preload-ready setup

config :my_app, MyAppWeb.Endpoint,
  force_ssl: [
    rewrite_on: [:x_forwarded_proto],
    hsts: true,
    expires: 63_072_000,
    subdomains: true,
    preload: true
  ]

My opinionated advice

For most Phoenix apps:

  1. enable HTTPS redirects
  2. send HSTS with a one-year max age
  3. wait on includeSubDomains until you’ve audited everything
  4. wait even longer on preload

HSTS is one of the easiest wins in web security. The trick is not the syntax. The trick is avoiding self-inflicted outages from overconfident flags.

Phoenix already gives you the plumbing. Use it carefully, verify the actual response headers, and don’t preload a domain you don’t fully control.