If you’re serving a Fiber app over HTTPS and you haven’t set HSTS yet, you’re leaving a pretty obvious gap in transport security.
HSTS tells the browser: “stop trying plain HTTP for this site, always use HTTPS.” That blocks protocol downgrade attacks, strips out a whole class of SSL-stripping nonsense, and reduces accidental insecure requests from users who type example.com instead of https://example.com.
For Go developers, the good news is that HSTS is just a response header. The bad news is that it’s also one of those headers that can lock users into a bad config if you ship it carelessly.
Here’s how I set it up in Fiber, how I roll it out safely, and what I avoid.
What HSTS actually does
The header looks like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Once a browser sees that header over a valid HTTPS connection, it remembers that your domain should only be accessed via HTTPS for the max-age period.
That means:
http://example.comgets rewritten tohttps://example.comby the browser- downgrade attacks become much harder
- users can’t click through certificate warnings once HSTS is active
That last part is the one people forget. If your cert breaks, HSTS users are stuck until you fix it. That’s exactly what you want from a security standpoint, but only if your TLS setup is reliable.
HSTS prerequisites
Before you enable HSTS in production, make sure all of this is true:
- your site works correctly on HTTPS
- HTTP requests redirect cleanly to HTTPS
- every subdomain is ready if you plan to use
includeSubDomains - your TLS cert renewal process is boring and dependable
- you are not sending HSTS on localhost or random dev environments
If any of that sounds shaky, fix that first.
Basic HSTS in Fiber
Fiber includes middleware for security headers via Helmet. That’s the easiest way to start.
Install Fiber if you haven’t already:
go get github.com/gofiber/fiber/v2
go get github.com/gofiber/fiber/v2/middleware/helmet
Now wire up Helmet with HSTS enabled:
package main
import (
"log"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/helmet"
)
func main() {
app := fiber.New()
app.Use(helmet.New(helmet.Config{
HSTSMaxAge: 31536000,
HSTSExcludeSubdomains: false,
ContentSecurityPolicy: "default-src 'self'",
}))
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("Hello over HTTPS")
})
log.Fatal(app.Listen(":3000"))
}
With that config, Fiber will send:
Strict-Transport-Security: max-age=31536000; includeSubDomains
A one-year max age is standard. 31536000 is 365 days in seconds.
Manual HSTS header in Fiber
I still like knowing how to set it myself, especially when I want exact control or conditional logic.
Here’s the manual version:
package main
import (
"log"
"github.com/gofiber/fiber/v2"
)
func main() {
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
return c.Next()
})
app.Get("/", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"message": "secure response",
})
})
log.Fatal(app.Listen(":3000"))
}
That’s all HSTS is at the app level: one response header.
Redirect HTTP to HTTPS first
HSTS does nothing for the very first visit if the user comes in over plain HTTP. The browser only learns the policy after seeing the header on HTTPS.
So you still need HTTP to HTTPS redirects.
If Fiber is directly handling both ports, you can run a small redirect app on port 80 and your main app on 443. In practice, most teams do this at Nginx, Caddy, Traefik, AWS ALB, or Cloudflare. That’s cleaner.
A minimal Fiber redirect service looks like this:
package main
import (
"log"
"github.com/gofiber/fiber/v2"
)
func main() {
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
host := c.Hostname()
url := "https://" + host + c.OriginalURL()
return c.Redirect(url, fiber.StatusMovedPermanently)
})
log.Fatal(app.Listen(":80"))
}
Then your HTTPS app serves the actual site and sends the HSTS header.
If you’re behind a reverse proxy, make sure Fiber is configured to trust proxy headers correctly. Otherwise your app may misread the scheme or host.
Safe rollout strategy
This is where people get reckless.
Don’t start with a one-year policy and includeSubDomains unless you know every domain and subdomain is ready. I’ve seen internal dashboards and forgotten staging hosts get broken this way.
A safer rollout is:
- start with a short
max-age - verify redirects and TLS everywhere
- increase
max-age - add
includeSubDomainsonly when you’re sure - consider preload only when you really mean it
Example staged config:
Stage 1: one hour
app.Use(func(c *fiber.Ctx) error {
c.Set("Strict-Transport-Security", "max-age=3600")
return c.Next()
})
Stage 2: one week
app.Use(func(c *fiber.Ctx) error {
c.Set("Strict-Transport-Security", "max-age=604800")
return c.Next()
})
Stage 3: one year with subdomains
app.Use(func(c *fiber.Ctx) error {
c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
return c.Next()
})
That progression gives you room to catch mistakes before browsers cache your policy for months.
When to use includeSubDomains
Use it when all subdomains are HTTPS-only and stay that way.
Don’t use it if you have:
- legacy HTTP-only subdomains
- forgotten internal tools exposed under your main domain
- weird mail or device interfaces hanging off subdomains
- staging environments that aren’t consistently secured
If admin.example.com or old.example.com is broken over HTTPS, includeSubDomains turns that into a real outage for HSTS-enabled clients.
HSTS preload
You’ll also see this form:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
preload is a signal that you want your domain added to browser preload lists, so browsers treat it as HTTPS-only even on first visit.
That’s powerful, but it’s not something I enable casually.
Preload generally requires:
max-ageof at least 1 yearincludeSubDomains- valid HTTPS across the whole domain tree
- a redirect from HTTP to HTTPS
And once your domain is preloaded, backing out can take time and is annoying.
If you don’t specifically need preload, regular HSTS is already a strong improvement.
Don’t send HSTS in local dev
Browsers cache HSTS aggressively. If you send it on a dev hostname and then your setup changes, you can end up debugging fake “TLS issues” that are really browser state.
A simple pattern is to only set HSTS in production:
package main
import (
"log"
"os"
"github.com/gofiber/fiber/v2"
)
func main() {
app := fiber.New()
env := os.Getenv("APP_ENV")
if env == "production" {
app.Use(func(c *fiber.Ctx) error {
c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
return c.Next()
})
}
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("ok")
})
log.Fatal(app.Listen(":3000"))
}
That one small guard saves a lot of pointless pain.
Verify the header
After deployment, check the actual response headers from the public edge, not just what your app thinks it’s sending.
You can use curl:
curl -I https://example.com
You want to see something like:
Strict-Transport-Security: max-age=31536000; includeSubDomains
I also like using a header scanner because it catches adjacent issues too. If you want a quick external check, run your site through HeaderTest and confirm HSTS is present at the edge where browsers actually see it.
Common mistakes
These are the HSTS mistakes I see most often:
1. Setting HSTS before HTTPS is fully stable
If your cert renewal is flaky or some paths still bounce strangely, fix that first.
2. Using includeSubDomains too early
This is the classic self-own. Inventory your subdomains before you flip that on.
3. Sending HSTS only from the app, but not from cached/static responses
If your CDN, proxy, or static asset path bypasses the app, make sure the header is still applied where needed.
4. Testing in a browser once and assuming it’s done
Browsers cache HSTS. You can hide bad configs from yourself if you only test with a warm browser. Use fresh profiles, command-line tools, and external scanners.
5. Trying to “turn it off” instantly
You technically can send:
Strict-Transport-Security: max-age=0
That tells browsers to forget the policy. But if clients already cached a long duration, you still have to wait until they receive the new response over HTTPS. If your cert is broken, they may not be able to get that response at all.
That’s why careful rollout matters.
A production-ready Fiber example
Here’s a practical setup with HTTPS redirect assumptions, environment gating, and a sane long-term HSTS policy:
package main
import (
"log"
"os"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/helmet"
)
func main() {
app := fiber.New()
if os.Getenv("APP_ENV") == "production" {
app.Use(helmet.New(helmet.Config{
HSTSMaxAge: 31536000,
HSTSExcludeSubdomains: false,
Frameguard: true,
XSSProtection: true,
NoSniff: true,
}))
}
app.Get("/healthz", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("Fiber app with HSTS")
})
log.Fatal(app.Listen(":3000"))
}
That’s enough for most apps, assuming TLS termination and redirects are handled properly upstream.
HSTS is one of those rare security controls that’s cheap, effective, and easy to maintain once it’s set correctly. Just respect the blast radius. A bad CSP usually breaks a page. Bad HSTS can brick access to your site until you fix TLS. That’s why I roll it out slowly, verify from the edge, and only add subdomains when I’m absolutely sure they’re ready.