If you run a Play app in production and you’re still treating HTTPS as “mostly enabled,” HSTS is one of the easiest ways to stop users from ever hitting your site over plain HTTP again.

The idea is simple: tell the browser, “for this domain, only use HTTPS for a while.” After that, even if someone clicks an old http:// link or a network attacker tries SSL stripping, the browser upgrades the request before it leaves the machine.

For Scala teams on Play Framework, the real question isn’t whether HSTS is useful. It’s where to set it, how aggressively to enable it, and what tradeoffs come with each approach.

What HSTS does in practice

The header looks like this:

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

That tells the browser:

  • remember this policy for 1 year
  • apply it to all subdomains too
  • refuse insecure HTTP for that period

You can also add preload, but that’s a separate commitment and not something I’d casually slap on during a sprint.

Your main options in Play

For Play apps, there are usually three ways teams handle HSTS:

  1. Set it in Play itself
  2. Set it at the reverse proxy or load balancer
  3. Set it in both places, with one clearly authoritative

Each works. Each has tradeoffs.


Option 1: Configure HSTS in Play Framework

If your app is directly responsible for response headers, Play can add HSTS cleanly.

Play ships with security headers support via SecurityHeadersFilter. In many setups, this is the easiest place to start because the policy lives with app code and gets versioned with the service.

Example Play configuration

In application.conf:

play.filters.enabled += "play.filters.headers.SecurityHeadersFilter"

play.filters.headers {
  strictTransportSecurity = "max-age=31536000; includeSubDomains"
  xFrameOptions = "DENY"
  xXSSProtection = "1; mode=block"
  xContentTypeOptions = "nosniff"
  contentSecurityPolicy = "default-src 'self'"
}

If you only care about HSTS right now, the key line is:

play.filters.headers.strictTransportSecurity = "max-age=31536000; includeSubDomains"

Pros

  • Version-controlled with the app
    I like this because header policy changes travel through normal code review and deployment.

  • Easy for app teams to own
    No waiting on platform or infra teams just to tweak one header.

  • Consistent across environments where Play serves traffic
    Handy for simpler deployments.

Cons

  • Only works if Play actually serves the HTTPS response
    If TLS terminates at Nginx, HAProxy, AWS ALB, Cloudflare, or some ingress layer, your app may not be the best source of truth.

  • Can get messy in multi-service setups
    If every service defines its own HSTS policy, you’ll eventually get drift.

  • Risk of accidental local/dev pain
    If you test on real domains and enable aggressive HSTS too early, browsers cache it and debugging becomes annoying fast.

My take

This is a solid choice for small to medium Play deployments where the app team owns the full stack or where the proxy layer is intentionally thin.

If your infrastructure is more centralized, app-level HSTS often turns into duplicated configuration.


Option 2: Configure HSTS at the reverse proxy or load balancer

This is the option I usually prefer in production.

If TLS is terminated before Play sees the request, the reverse proxy is the natural place to attach Strict-Transport-Security. That’s the layer actually guaranteeing HTTPS delivery.

Nginx example

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

    ssl_certificate     /etc/ssl/cert.pem;
    ssl_certificate_key /etc/ssl/key.pem;

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

    location / {
        proxy_pass http://play_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

The always flag matters. Without it, some responses may miss the header.

HAProxy example

frontend https
    bind *:443 ssl crt /etc/haproxy/certs/example.pem
    http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"
    default_backend play_app

Pros

  • Best place when TLS terminates outside Play
    That’s the big one.

  • One policy for many services
    Easier to standardize across multiple Scala services or mixed-language stacks.

  • Less application duplication
    Security headers become part of platform policy instead of per-app trivia.

Cons

  • App teams may not control it directly
    Changes can be slower if infra ownership is separate.

  • Can hide mistakes from developers
    The app looks fine in prod, but local/staging environments may behave differently.

  • Misconfigured proxies can still break things
    I’ve seen people set HSTS on one virtual host and forget another, or miss redirects entirely.

My take

For serious production setups, this is usually the cleanest approach. If the proxy terminates TLS, let the proxy own HSTS.


Option 3: Set HSTS in both Play and the proxy

This sounds redundant because it is. But sometimes redundancy is useful.

Some teams put HSTS in Play as a fallback and also enforce it at the edge. That can work, as long as both places emit the same value and everyone understands which layer is authoritative.

Pros

  • Defense in depth
  • Safer during infrastructure transitions
  • Can help in hybrid environments

Cons

  • Easy to create conflicting values
  • Harder to reason about
  • More config drift over time

If Play sends:

Strict-Transport-Security: max-age=300

and Nginx sends:

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

you’ve created a policy puzzle for no real benefit.

My take

I only like this temporarily during migrations. Long term, one layer should own it.


Pros of enabling HSTS for Play apps

No matter where you configure it, HSTS has some very real upside.

1. Protection against downgrade and SSL stripping

This is the whole point. Browsers stop attempting insecure HTTP after they learn the policy.

2. Cleaner HTTPS enforcement

Redirecting http:// to https:// is good. HSTS is better because the browser skips the insecure request entirely after the first secure visit.

3. Low maintenance once stable

After rollout, HSTS is mostly boring. Security controls that stay boring are my favorite kind.

4. Strong signal of mature transport security

If you’re already enforcing HTTPS correctly, not using HSTS feels like leaving the job half-finished.


Cons and gotchas of HSTS

This is where people get burned.

1. Bad HTTPS setup becomes unrecoverable for users

If your cert expires, your TLS config breaks, or a subdomain has broken HTTPS and you used includeSubDomains, users can’t click through and proceed casually. The browser blocks hard.

That’s not a bug. That’s the feature. But it means you need operational discipline.

2. includeSubDomains is a serious commitment

Don’t enable this unless every subdomain is HTTPS-ready or intentionally nonexistent.

I’ve seen organizations break ancient admin panels, forgotten mail hosts, and random marketing microsites this way.

3. Preload is even more serious

Preload means asking browsers to hardcode your domain into their HSTS preload lists. Great when you’re ready. Painful if you are not.

I would not preload a domain unless:

  • HTTPS is solid everywhere
  • redirects are correct
  • subdomains are under control
  • the organization understands rollback is slow

4. Local testing can get weird

If you test with real subdomains and cache HSTS in your browser, you may spend an afternoon wondering why HTTP “stopped working.”


A sane rollout strategy for Play teams

This is the approach I recommend.

Phase 1: Start small

Use a short max-age first:

play.filters.headers.strictTransportSecurity = "max-age=300"

That’s 5 minutes. Enough to validate behavior without locking users in for months.

Phase 2: Increase gradually

Move to something like:

play.filters.headers.strictTransportSecurity = "max-age=86400"

Then later:

play.filters.headers.strictTransportSecurity = "max-age=31536000"

Phase 3: Add includeSubDomains only when you’re sure

Don’t treat this as a default checkbox.

Phase 4: Consider preload last

Only after the rest has been stable for a while.


How to verify your header

Don’t trust config files. Check real responses.

From the command line:

curl -I https://example.com

You want to see:

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

You should also test redirects and subdomains, not just the homepage.

If you want a quick external check, run a free security headers scan at HeaderTest. It’s a fast way to catch missing or inconsistent header behavior.


Which option should you choose?

Here’s the blunt version:

  • Use Play-level HSTS if your app team owns delivery end-to-end and Play is the logical place for security headers.
  • Use proxy-level HSTS if TLS terminates before Play. This is the best default for most production systems.
  • Use both only temporarily during migrations or architecture cleanup.

If I were setting up a Play app behind Nginx or a cloud load balancer today, I’d put HSTS at the edge, keep the policy documented with the app, and roll it out gradually with a short max-age first.

That gets you the security benefit without the classic “we enabled includeSubDomains and accidentally bricked three forgotten hosts” story nobody wants to explain in postmortem notes.