If you run a Gin app over HTTPS and you’re not sending HSTS, you’re leaving an easy downgrade path open. HSTS tells browsers: “stop trying plain HTTP for this site; always use HTTPS.” That shuts down a bunch of avoidable mistakes and some very real attack paths.

The catch: HSTS is one of those headers that looks trivial but can absolutely bite you in production if you roll it out carelessly. I’ve seen teams turn it on with preload flags before they were ready, then spend days untangling broken subdomains and internal tools.

Here’s the practical comparison guide for HSTS in Go with Gin: what it does well, where it hurts, and the common ways to implement it.

What HSTS actually does

The header looks like this:

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

When a browser sees it over a valid HTTPS connection, it remembers that your domain should only be visited via HTTPS for the max-age duration.

That gives you a few concrete wins:

  • blocks protocol downgrade from https:// to http://
  • helps protect users against SSL stripping on first-party revisits
  • prevents users from clicking through certain certificate warning flows
  • enforces HTTPS at the browser level, not just at your app or load balancer

What HSTS does not do:

  • it does not protect the very first visit unless you’re preloaded
  • it does not replace redirecting HTTP to HTTPS
  • it does not fix bad TLS config
  • it does not help non-browser clients that ignore the header

That distinction matters because people often treat HSTS like a silver bullet. It isn’t. It’s one strong layer.

Option 1: Set HSTS directly in Gin middleware

This is the simplest and usually my preferred approach.

package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func HSTSMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		// Only send HSTS on HTTPS responses
		if c.Request.TLS != nil {
			c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
		}
		c.Next()
	}
}

func main() {
	r := gin.Default()
	r.Use(HSTSMiddleware())

	r.GET("/", func(c *gin.Context) {
		c.String(http.StatusOK, "secure hello")
	})

	r.RunTLS(":8443", "server.crt", "server.key")
}

Pros

  • dead simple
  • no extra dependency
  • easy to review in code
  • easy to customize by environment

Cons

  • easy to get wrong behind a reverse proxy
  • every service may implement it slightly differently
  • no built-in policy management if you want broader header coverage

The proxy issue is the one that gets teams. If Gin is behind Nginx, HAProxy, ALB, or Cloudflare, c.Request.TLS may be nil because TLS terminates upstream. In that case, you need to trust forwarded headers carefully or set HSTS at the proxy layer instead.

A proxy-aware version might look like this:

func HSTSMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		isHTTPS := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
		if isHTTPS {
			c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
		}
		c.Next()
	}
}

That’s fine if you control the proxy and trust that header. If you don’t, don’t blindly accept X-Forwarded-Proto from the public internet.

Option 2: Use gin-contrib/secure

If you want more than HSTS and prefer a package that handles a bundle of security headers, gin-contrib/secure is a reasonable option.

package main

import (
	"github.com/gin-contrib/secure"
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	r.Use(secure.New(secure.Config{
		SSLRedirect:           true,
		STSSeconds:            31536000,
		STSIncludeSubdomains:  true,
		STSPreload:            false,
		FrameDeny:             true,
		ContentTypeNosniff:    true,
		BrowserXssFilter:      true,
	}))

	r.GET("/", func(c *gin.Context) {
		c.String(200, "ok")
	})

	r.Run(":8080")
}

Pros

  • fast setup
  • one place for several useful security headers
  • less custom code to maintain
  • good fit for standard apps

Cons

  • less explicit than hand-written middleware
  • easy for teams to cargo-cult config they don’t fully understand
  • some header defaults may not match your actual app behavior

I like this option for small to mid-sized services where consistency matters more than fine-grained control. I’m less enthusiastic when teams just copy a config block from a blog post and never revisit it. Security middleware should match your architecture, not somebody else’s demo app.

Also, BrowserXssFilter is a bit of legacy baggage in modern contexts. Don’t confuse “lots of headers enabled” with “well-secured.”

Option 3: Set HSTS at the reverse proxy or load balancer

This is often the best operational choice if TLS terminates before your Gin app.

For Nginx:

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

For Caddy:

header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
}

Pros

  • correct place if TLS terminates at the edge
  • applies consistently across multiple backend services
  • avoids app-level mistakes around HTTPS detection
  • easier to standardize across teams

Cons

  • policy lives outside app code
  • local dev and staging can drift from prod
  • developers may forget the header exists because they never see it in the service

If your Gin app sits behind an ingress or edge proxy, I usually prefer HSTS at the edge. That’s where HTTPS is real, and that’s where the policy should usually live.

Still, you need app teams to know it exists. Otherwise they’ll test locally, miss the header, and assume nothing is configured.

Comparing HSTS strategies for Gin

Here’s the practical breakdown.

App middleware

Best when:

  • the Gin app terminates TLS itself
  • you want code-level visibility
  • you need per-route or per-environment logic

Avoid when:

  • TLS terminates upstream
  • multiple services need centralized policy

gin-contrib/secure

Best when:

  • you want quick baseline hardening
  • one middleware package for several headers is good enough
  • your app has pretty standard behavior

Avoid when:

  • you need tight control over every header
  • your team tends to enable settings without understanding side effects

Reverse proxy / load balancer

Best when:

  • TLS terminates at the edge
  • many apps sit behind the same platform
  • security policy is managed centrally

Avoid when:

  • your infra setup is inconsistent across environments
  • developers have no visibility into edge config

The real pros of HSTS

HSTS is one of the rare security headers that gives strong value for very little runtime cost.

1. Stronger HTTPS enforcement

Redirects are not enough. A user can still start at http://example.com and get redirected. HSTS tells the browser to skip that dance on future visits and go straight to HTTPS.

2. Better resistance to SSL stripping

On hostile networks, downgrade attacks are still a thing. HSTS makes them much harder once the browser has learned your policy.

3. Reduces bad user decisions

Browsers are less likely to let users click through certificate issues on HSTS-protected sites. That’s good. Users are terrible at judging TLS warnings.

4. Cheap to deploy

No app performance cost worth worrying about. It’s just a response header.

The real cons of HSTS

This is where people get careless.

1. You can lock users out

If you break HTTPS or misconfigure certs, HSTS can make your site unreachable for users with cached policy. That’s the point of the header, but it becomes your problem during outages.

2. includeSubDomains can break forgotten hosts

This flag is great when your domain is clean. It’s painful when you have old subdomains, internal tools, abandoned environments, or weird vendor endpoints.

Before enabling it, inventory your subdomains properly.

3. Preload is hard to undo

If you add preload and submit your domain to browser preload lists, you’re making a long-term commitment. Don’t do this because it “sounds more secure.” Do it only when you’re certain every present and future subdomain can live with mandatory HTTPS.

4. First-visit gap still exists without preload

Normal HSTS only helps after the browser has seen the header once. If you need protection on first contact, preload is the answer, but preload has a much bigger blast radius.

A rollout plan that doesn’t cause pain

This is the boring and safe way, which is usually the right way.

Start small:

Strict-Transport-Security: max-age=300

Then increase after validation:

Strict-Transport-Security: max-age=86400

Then move to a long-lived policy:

Strict-Transport-Security: max-age=31536000

Only later consider:

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

And only after serious review:

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

That rollout catches mistakes while they’re still reversible.

How to verify your Gin app

Use curl first:

curl -I https://yourdomain.com

You want to see:

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

If you want a quick external check, run a free scan at HeaderTest. It’s a fast way to confirm HSTS and spot missing security headers around it.

My recommendation

For a Gin app that terminates TLS directly, I’d usually start with simple custom middleware or gin-contrib/secure if I also want a few other baseline headers.

For a production setup behind Nginx, Caddy, Kubernetes ingress, or a cloud load balancer, I’d rather set HSTS at the edge. That’s cleaner and less error-prone.

My default advice:

  • enable HSTS on all real HTTPS sites
  • start with a short max-age
  • don’t rush includeSubDomains
  • don’t touch preload until you’ve done a real subdomain audit

HSTS is absolutely worth using in Go with Gin. The main question isn’t whether you should enable it. The real question is where to manage it and how much risk your domain structure can handle.