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=31536000means remember HTTPS-only for 1 yearincludeSubDomainsapplies the rule to all subdomainspreloadsays 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.comstatus.example.comm.example.comdev.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.
Recommended setups
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:
- enable HTTPS redirects
- send HSTS with a one-year max age
- wait on
includeSubDomainsuntil you’ve audited everything - 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.