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.comapi.example.comadmin.example.comold-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:
- Enable HTTPS everywhere
- Add HSTS with a small
max-age - Increase
max-agegradually - Add
includeSubDomainsafter verification - Add
preloadonly when you’re fully ready
For preload eligibility, you generally need:
max-ageof at least 31536000includeSubDomainspreload- 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:
- Redirect all HTTP traffic to HTTPS at Nginx or Apache
- Fix proxy trust so Laravel correctly understands secure requests
- Set HSTS at the edge, not just in PHP
- Start with low
max-age - Add
includeSubDomainsonly after checking every relevant host - Add
preloadonly 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.