I’ve seen the same Render setup more than once: HTTPS is enabled, the app works, redirects are in place, and everyone assumes transport security is “done.” Then someone runs a header scan and realizes the site still skips one of the most useful protections you can ship in five minutes: HSTS.

This is a real-world style case study based on a common Render deployment pattern. Same stack, same hosting model, same mistake.

The setup

A small SaaS marketing site and API were deployed on Render:

  • Frontend: static site on Render
  • Backend: Node/Express web service on Render
  • Custom domain attached
  • Render-managed TLS enabled
  • HTTP redirected to HTTPS

From a product perspective, everything looked fine. The team had the padlock. No browser warnings. No obvious issues.

From a security perspective, the site was still missing a guardrail.

The problem

The application redirected HTTP to HTTPS, but it did not send the Strict-Transport-Security header.

That matters because redirects alone are not enough. On a user’s first visit, a browser may still attempt plain HTTP before it learns to use HTTPS. That leaves room for downgrade and interception attacks on hostile networks.

HSTS tells the browser:

  • only use HTTPS for this domain
  • keep doing that for a defined period
  • optionally apply the rule to subdomains too

If you serve real traffic over a custom domain, I think HSTS should be your default unless you have a very specific reason not to use it.

Before: what the Render deployment looked like

The team had this Express app behind Render:

import express from "express";

const app = express();

app.use((req, res, next) => {
  if (req.header("x-forwarded-proto") !== "https") {
    return res.redirect(301, `https://${req.hostname}${req.originalUrl}`);
  }
  next();
});

app.get("/", (req, res) => {
  res.send("Hello from Render");
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

Looks reasonable. Render terminates TLS at the edge and forwards requests to the app. The redirect uses x-forwarded-proto, which is the right idea on a proxy-based platform.

But the response headers looked like this:

HTTP/2 200
content-type: text/html; charset=utf-8
x-powered-by: Express

No HSTS.

A quick scan with Headertest flagged it immediately.

Why this happens on Render

Render gives you HTTPS termination, certificates, and redirect support, but HSTS is still your responsibility at the application or edge configuration layer.

That catches people off guard. They assume “managed TLS” means “managed transport policy.” It doesn’t.

TLS answers: can we encrypt this connection?

HSTS answers: will the browser refuse to ever use HTTP for this site after learning the policy?

Different jobs.

The fix

For the Express service, the cleanest fix was to set the header on all HTTPS responses.

The team switched to this:

import express from "express";

const app = express();

app.set("trust proxy", true);

app.use((req, res, next) => {
  if (!req.secure) {
    return res.redirect(301, `https://${req.hostname}${req.originalUrl}`);
  }

  res.setHeader(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains"
  );

  next();
});

app.get("/", (req, res) => {
  res.send("Hello from Render");
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

Two changes matter here:

  1. app.set("trust proxy", true)
    Without this, req.secure may be wrong behind Render’s proxy.

  2. The HSTS header is only sent on HTTPS responses
    That’s how it should be. Browsers ignore HSTS over plain HTTP anyway.

After deployment, the headers looked like this:

HTTP/2 200
content-type: text/html; charset=utf-8
strict-transport-security: max-age=31536000; includeSubDomains

That’s already a big improvement.

Before and after for a static site on Render

The static-site version of this problem is even more common.

Before

A frontend app had a _redirects file:

http://example.com/* https://example.com/:splat 301!

Traffic got pushed to HTTPS, but the final HTTPS response didn’t include HSTS.

After

The team added a _headers file to the static site:

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

That’s it.

If you’re serving a static site on Render, _headers is usually the easiest place to manage HSTS and other security headers.

The rollout mistake I see most often

People jump straight to this:

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

That can be fine later. I would not start there.

HSTS is sticky. Once a browser caches it, you can’t easily undo the effect for that user until max-age expires. If you include subdomains and one of them still serves mixed or broken TLS, you just created your own outage.

The safer rollout is staged.

Stage 1: short max-age

Start with something small:

Strict-Transport-Security: max-age=300

Five minutes is enough to verify behavior without committing users for months.

Stage 2: increase after validation

If everything looks good:

Strict-Transport-Security: max-age=86400

Then move to:

Strict-Transport-Security: max-age=31536000

Stage 3: add subdomains only when you mean it

Only add this when every subdomain is actually ready:

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

Stage 4: consider preload carefully

If you want preload, you need to meet browser preload requirements and be very sure every subdomain is permanently HTTPS-only.

Example:

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

Preload is not something I enable casually. It’s great when you’re ready. It’s painful when you’re not.

For preload requirements and browser behavior, check the official docs at MDN’s HSTS reference.

The actual outcome

After adding HSTS and rolling it out in stages, the team ended up with:

  • HTTPS enforced by redirect
  • browser-side HTTPS pinning via HSTS
  • cleaner scanner results
  • less exposure to downgrade attacks on first-return visits
  • fewer questions during customer security reviews

That last one matters more than people admit. Security questionnaires love asking whether HSTS is enabled. It’s low effort and high signal.

A couple of Render-specific gotchas

1. Don’t forget trust proxy for Express

If you’re checking req.secure on Render and haven’t enabled proxy trust, your app may think every request is HTTP.

Use:

app.set("trust proxy", true);

I’ve debugged this one enough times that I now add it almost by muscle memory on hosted Node platforms.

2. Don’t set HSTS on preview or temporary domains unless you understand the impact

If you use lots of test subdomains or temporary environments, be careful with includeSubDomains. One aggressive HSTS policy at the parent domain can affect all of them.

3. Don’t ship HSTS if some subdomains still need HTTP

This sounds obvious, but internal tools, legacy callbacks, and old asset hosts are where HSTS rollouts go to die.

Inventory the domain space first.

A practical baseline I’d use

For a typical production Render deployment on a custom domain, this is a sane starting header:

Strict-Transport-Security: max-age=31536000

Then, once you verify all subdomains are clean:

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

And only after that, maybe preload.

How to verify it

You can verify HSTS in a few ways.

Browser devtools

Check the response headers on the HTTPS response.

curl

curl -I https://yourdomain.com

You want to see:

Strict-Transport-Security: max-age=31536000

Header scanner

Use Headertest for a quick pass, especially if you want to confirm the header is present across redirects and final responses.

Official docs

For Render deployment behavior and config options, check the official docs at Render Documentation.

The final state

Here’s the “after” Express version I’d actually keep in production:

import express from "express";

const app = express();

app.set("trust proxy", true);

app.use((req, res, next) => {
  if (!req.secure) {
    return res.redirect(301, `https://${req.hostname}${req.originalUrl}`);
  }

  res.setHeader("Strict-Transport-Security", "max-age=31536000");
  next();
});

app.get("/", (req, res) => {
  res.send("Secure on Render");
});

const port = process.env.PORT || 3000;
app.listen(port);

And for a Render static site:

_headers

/*
  Strict-Transport-Security: max-age=31536000

That’s the whole fix.

If your Render deployment already has HTTPS, adding HSTS is one of those rare security tasks that is both easy and genuinely useful. I’d call it baseline hardening, not “extra credit.”