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.com gets rewritten to https://example.com by 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:

  1. start with a short max-age
  2. verify redirects and TLS everywhere
  3. increase max-age
  4. add includeSubDomains only when you’re sure
  5. 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-age of at least 1 year
  • includeSubDomains
  • 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.