HSTS is one of those headers that’s easy to add and surprisingly easy to get wrong.
If you run a Go app with Echo, you can enable it in a few lines. The hard part is choosing the right policy, rolling it out safely, and not locking yourself into a bad preload decision.
This is the reference I wish more teams had handy.
What HSTS does
Strict-Transport-Security tells browsers:
- always use HTTPS for this site
- for a period of time you define
- optionally for all subdomains too
- optionally with preload eligibility
A typical header looks like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains
That means:
max-age=31536000→ remember HTTPS-only for 1 yearincludeSubDomains→ apply toapi.example.com,app.example.com, and so on
Once a browser has seen this header over HTTPS, future HTTP requests get upgraded to HTTPS before the request leaves the browser.
That blocks a whole class of downgrade and SSL-stripping attacks.
The rules that matter
A few practical rules:
-
Only send HSTS over HTTPS Browsers ignore it over plain HTTP anyway.
-
Don’t enable
includeSubDomainsunless every subdomain is HTTPS-ready I’ve seen this break old admin panels, forgotten staging hosts, and random vendor subdomains. -
Don’t add
preloadcasually Preload is effectively a long-term commitment. -
Start with a short
max-ageif you’re unsure Roll out safely, then increase it.
Minimal HSTS in Echo
If you just want the header on all HTTPS responses, this middleware is enough:
package main
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 31536000, // 1 year
}))
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "hello")
})
e.Logger.Fatal(e.StartTLS(":443", "server.crt", "server.key"))
}
That produces:
Strict-Transport-Security: max-age=31536000
If you’re already terminating TLS at a reverse proxy like Nginx, Caddy, ALB, or Cloudflare, your Echo app may still run on plain HTTP internally. In that setup, you need to be careful about where the header gets set.
My preference: set HSTS at the edge proxy if that’s where HTTPS actually terminates. That keeps the policy closest to the browser-facing layer.
HSTS with subdomains
If your whole domain is HTTPS-only, enable subdomains too:
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 31536000,
HSTSExcludeSubdomains: false,
}))
In Echo, HSTSExcludeSubdomains: false means includeSubDomains will be added.
Header result:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Use this only when you’re confident every subdomain is covered.
A safer rollout plan
For production systems, I usually roll out HSTS in phases.
Phase 1: short max-age
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 300, // 5 minutes
}))
Watch for:
- redirect loops
- mixed-content issues
- old subdomains still serving HTTP
- broken health checks or callbacks
Phase 2: increase to a day or a week
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 86400, // 1 day
}))
Then:
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 604800, // 1 week
}))
Phase 3: move to a year
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 31536000, // 1 year
}))
That progression has saved people from painful mistakes more than once.
Full production-oriented Echo setup
Here’s a more realistic example with HTTPS redirect and HSTS:
package main
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
// Redirect HTTP to HTTPS
e.Pre(middleware.HTTPSRedirect())
// Security headers including HSTS
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
XSSProtection: "1; mode=block",
ContentTypeNosniff: "nosniff",
XFrameOptions: "DENY",
HSTSMaxAge: 31536000,
HSTSExcludeSubdomains: false, // adds includeSubDomains
}))
e.GET("/", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{
"status": "ok",
})
})
e.Logger.Fatal(e.Start(":8080"))
}
A caveat here: HTTPSRedirect() works when Echo can correctly determine the original scheme. If you’re behind a reverse proxy, make sure forwarded headers are configured properly.
For example, when running behind a trusted proxy:
e.IPExtractor = echo.ExtractIPDirect()
And ensure your proxy sends X-Forwarded-Proto: https. Otherwise your app can get confused and create bad redirects.
Custom HSTS middleware
Sometimes Echo’s built-in secure middleware is fine, but you want explicit control. I like writing a tiny custom middleware when I need conditional behavior.
package main
import (
"net/http"
"strings"
"github.com/labstack/echo/v4"
)
func HSTSMiddleware(maxAge int, includeSubdomains bool, preload bool) echo.MiddlewareFunc {
value := "max-age=" + strconv.Itoa(maxAge)
if includeSubdomains {
value += "; includeSubDomains"
}
if preload {
value += "; preload"
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Only set HSTS when the original request is HTTPS
scheme := c.Scheme()
if strings.EqualFold(scheme, "https") {
c.Response().Header().Set("Strict-Transport-Security", value)
}
return next(c)
}
}
}
func main() {
e := echo.New()
e.Use(HSTSMiddleware(31536000, true, false))
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
e.Logger.Fatal(e.Start(":8080"))
}
You’ll need this import too:
import "strconv"
This version is nice when you want to:
- skip HSTS in local environments
- apply different values by hostname
- keep preload disabled unless a feature flag is on
Preload: only if you mean it
If you want preload eligibility, the header must look like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Echo config for that:
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 31536000,
HSTSExcludeSubdomains: false,
HSTSPreloadEnabled: true,
}))
Preload means browsers can ship your domain in their built-in HTTPS-only list.
That sounds great, and it is, but there’s a catch: removing yourself later is slow and annoying. If any subdomain still needs HTTP, don’t preload. If your company likes spawning random subdomains for experiments, definitely don’t preload yet.
Local development
Do not turn on real HSTS for localhost in a way that pollutes your browser profile for normal development. You can end up with confusing HTTPS behavior that sticks around longer than expected.
A common pattern is to enable HSTS only outside development:
package main
import (
"os"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
if os.Getenv("APP_ENV") == "production" {
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 31536000,
HSTSExcludeSubdomains: false,
}))
}
// routes...
}
That’s boring, but boring is good here.
How to verify it
Use curl first:
curl -I https://example.com
You want to see something like:
Strict-Transport-Security: max-age=31536000; includeSubDomains
If you want a quick external check for HSTS and the rest of your security headers, run a free scan at HeaderTest.
Common mistakes
Setting HSTS on HTTP responses
Browsers ignore it. Don’t rely on it.
Sending includeSubDomains too early
This is the classic self-own. One forgotten subdomain can become a support incident.
Using a tiny max-age forever
A 5-minute or 1-day policy is fine during rollout, not as your steady state.
Preloading before your DNS and subdomain situation is clean
Preload is for mature setups, not messy ones.
Forgetting the reverse proxy layer
If TLS terminates before Echo, check whether:
- the edge is setting HSTS already
- Echo sees the original scheme correctly
- redirects and canonical URLs behave as expected
Recommended defaults
If your main site is fully HTTPS, but you’re not ready to guarantee every subdomain:
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 31536000,
}))
If your entire domain and all subdomains are HTTPS-only:
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 31536000,
HSTSExcludeSubdomains: false,
}))
If you are truly ready for preload:
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 31536000,
HSTSExcludeSubdomains: false,
HSTSPreloadEnabled: true,
}))
My opinionated default: use max-age=31536000, add includeSubDomains only after you’ve audited every subdomain, and treat preload like a one-way door. That keeps HSTS useful instead of turning it into a production surprise.