HSTS looks simple: send one header and the browser forces HTTPS from then on. In Laravel, that usually means one middleware or one web server config change.

And yet, teams still manage to break logins, lock users into bad cert setups, or preload domains before they’re actually ready. I’ve seen all three.

If you run a Laravel app, HSTS is worth doing. You just need to avoid the usual footguns.

Mistake #1: Turning on HSTS before HTTPS is truly clean

This is the classic one.

A team enables:

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

Then they discover one of these problems after the fact:

  • a subdomain still serves plain HTTP
  • an old internal tool has a broken certificate
  • assets load from mixed-content URLs
  • a CDN or load balancer is misconfigured
  • some environment still redirects in weird loops

Once a browser sees a valid HSTS policy, it stops giving users the option to “click through” TLS problems for that host. That’s the whole point. Great for security, terrible if your cert chain is a mess.

Fix

Before enabling HSTS, verify:

  • your main domain works over HTTPS with a valid cert
  • redirects from HTTP to HTTPS are consistent
  • subdomains are ready if you plan to use includeSubDomains
  • no mixed content remains
  • staging and admin subdomains aren’t forgotten

If you want a quick check, run a free security headers scan with HeaderTest and verify what your app is actually returning in production.

Start with a short max-age:

Strict-Transport-Security: max-age=300

Five minutes is boring on purpose. If everything behaves, increase it to a day, then a week, then a year.

Mistake #2: Setting HSTS in Laravel, but not on every response

A lot of Laravel examples add HSTS in app middleware and call it done:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class HstsMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        if ($request->isSecure()) {
            $response->headers->set(
                'Strict-Transport-Security',
                'max-age=31536000; includeSubDomains'
            );
        }

        return $response;
    }
}

That’s not wrong, but it’s incomplete in plenty of real deployments.

If Nginx, Apache, Cloudflare, or your load balancer serves redirects, error pages, cached responses, or static assets before Laravel runs, your app-level middleware may never get a chance to add the header.

Fix

Set HSTS as close to the edge as possible, usually at the web server or reverse proxy.

For Nginx:

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

The always part matters. Without it, some non-200 responses may miss the header.

For Apache:

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

Then, if you want, keep Laravel middleware too for consistency in local app behavior. But I trust edge config more for this header.

Mistake #3: Using includeSubDomains without checking every subdomain

includeSubDomains is where HSTS gets serious.

If you send:

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

you’re telling browsers to enforce HTTPS not only for example.com, but also for:

  • www.example.com
  • api.example.com
  • admin.example.com
  • old-crm.example.com
  • every forgotten weird subdomain someone created six years ago

That’s usually what you want eventually. It’s also how people accidentally break mail panels, legacy dashboards, or vendor-hosted tools.

Fix

Inventory your subdomains before enabling it.

At minimum, check:

  • production app
  • API
  • admin/back office
  • marketing site
  • docs/help center
  • status page
  • any customer-facing vanity subdomains
  • old DNS records nobody has touched in years

If you’re not sure they’re all clean, don’t use includeSubDomains yet. Start with the apex domain only:

Strict-Transport-Security: max-age=86400

Then add includeSubDomains later when you’ve actually verified the estate.

Mistake #4: Preloading too early

Preload is not just another flag to sprinkle in because a blog said so.

This header:

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

signals that you want your domain added to browser preload lists. Once accepted, browsers hardcode HTTPS for your domain before the first request even happens.

That’s powerful. It also raises the cost of mistakes dramatically.

I’ve seen teams preload while:

  • some subdomains still lacked HTTPS
  • certificates were managed manually and expired often
  • internal environments depended on public subdomains
  • they didn’t understand removal from preload lists can take time

Fix

Treat preload as the final stage, not the starting point.

Use this order:

  1. Enable HTTPS everywhere
  2. Add HSTS with a small max-age
  3. Increase max-age gradually
  4. Add includeSubDomains after verification
  5. Add preload only when you’re fully ready

For preload eligibility, you generally need:

  • max-age of at least 31536000
  • includeSubDomains
  • preload
  • valid HTTPS across the domain and subdomains

If your team is still fixing certificate issues in Slack every couple of months, you are not ready for preload.

Mistake #5: Trusting Request::isSecure() when proxies aren’t configured correctly

This one bites Laravel apps behind load balancers all the time.

Your app sits behind Nginx, AWS ELB, Cloudflare, or another proxy. TLS terminates there, and traffic reaches PHP over HTTP internally. Laravel may think the request is insecure unless trusted proxy settings are correct.

Then your HSTS middleware never runs because of this check:

if ($request->isSecure()) {
    // set HSTS
}

Fix

Make sure Laravel trusts your proxy headers.

In newer Laravel setups, configure trusted proxies properly so X-Forwarded-Proto is honored. Depending on your version and stack, that may look like this middleware setup:

namespace App\Http\Middleware;

use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;

class TrustProxies extends Middleware
{
    protected $proxies = '*';

    protected $headers =
        Request::HEADER_X_FORWARDED_FOR |
        Request::HEADER_X_FORWARDED_HOST |
        Request::HEADER_X_FORWARDED_PORT |
        Request::HEADER_X_FORWARDED_PROTO |
        Request::HEADER_X_FORWARDED_AWS_ELB;
}

Be careful with '*' in high-security environments; lock it down if you can. The main point is that Laravel must correctly detect HTTPS when sitting behind a proxy.

If this layer is flaky, move HSTS to the reverse proxy and stop depending on app logic for it.

Mistake #6: Sending HSTS on HTTP responses

Browsers ignore HSTS headers delivered over plain HTTP. That’s by design. An attacker on the network could inject fake HSTS policies otherwise.

So if your Laravel app or server sends this on port 80:

Strict-Transport-Security: max-age=31536000

it does nothing.

Fix

Only send HSTS over valid HTTPS responses, and make sure HTTP redirects to HTTPS immediately.

Nginx example:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

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

    # app config...
}

That’s the right pattern: redirect on HTTP, enforce HSTS on HTTPS.

Mistake #7: Using a one-year max-age on day one

People copy-paste the “recommended” one-year header immediately:

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

That’s fine when you know exactly what you’re doing. For everyone else, it’s reckless.

Browsers cache HSTS aggressively. If you ship a bad policy, users can stay stuck with it until it expires.

Fix

Roll it out in stages:

Strict-Transport-Security: max-age=300

Then:

Strict-Transport-Security: max-age=86400

Then:

Strict-Transport-Security: max-age=604800

Then the full version:

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

This staged rollout gives you room to catch mistakes before they become long-lived support problems.

Mistake #8: Forgetting that local and staging environments behave differently

Developers test production security behavior in staging, but staging often has:

  • self-signed certs
  • basic auth in front
  • temporary subdomains
  • weird redirects
  • half-finished proxy config

If you enable broad HSTS there and reuse production-like hostnames, you can create annoying browser caching behavior for your own team.

Fix

Keep HSTS strict in production, but be deliberate elsewhere.

In Laravel middleware, you can guard by environment:

if (app()->environment('production') && $request->isSecure()) {
    $response->headers->set(
        'Strict-Transport-Security',
        'max-age=31536000; includeSubDomains'
    );
}

I still prefer doing this at the web server level, but the principle stands: don’t casually force long-lived HSTS policies on disposable environments.

A sane Laravel HSTS setup

If I were setting this up on a normal Laravel production app today, I’d do this:

  1. Redirect all HTTP traffic to HTTPS at Nginx or Apache
  2. Fix proxy trust so Laravel correctly understands secure requests
  3. Set HSTS at the edge, not just in PHP
  4. Start with low max-age
  5. Add includeSubDomains only after checking every relevant host
  6. Add preload only when the domain is truly ready

A practical production header usually ends up here:

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

And later, if you’ve earned it:

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

HSTS is one of those controls that’s easy to configure and easy to misuse. Laravel doesn’t make it hard, but it also doesn’t protect you from bad rollout decisions. That part is still on us.