GraphQL APIs usually get plenty of attention around auth, query depth limits, and introspection. Transport security often gets treated like a box to check: “we already use HTTPS.” That’s not enough.

If your Apollo Server is reachable over plain HTTP, or if browsers can be tricked into making the first request insecurely, HTTPS alone leaves a gap. HSTS closes that gap for browser clients by telling them: from now on, only use HTTPS for this host.

For a GraphQL app, that matters most when Apollo Server sits behind a browser-facing app like Apollo Sandbox, an internal admin UI, a customer portal, or any frontend making GraphQL requests with cookies or bearer tokens. If a browser talks to your GraphQL endpoint, HSTS belongs on your checklist.

What HSTS actually does

HSTS stands for HTTP Strict Transport Security. It’s an HTTP response header:

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

When a browser receives that header over a valid HTTPS connection, it remembers the rule for max-age seconds. Future requests to that host get upgraded to HTTPS before the browser even hits the network.

That gives you a few real security wins:

  • Blocks protocol downgrade attempts
  • Reduces SSL stripping risk
  • Helps protect cookies and auth tokens from accidental HTTP exposure
  • Prevents users from clicking through many TLS certificate warnings once HSTS is cached

What it does not do:

  • It does not protect non-browser API clients unless they implement HSTS
  • It does not replace redirects from HTTP to HTTPS
  • It does not fix bad TLS config
  • It does not magically secure subdomains unless you explicitly include them

Where to set HSTS in an Apollo stack

Apollo Server can run in a few ways:

  • Standalone server
  • Apollo Server with Express
  • Apollo Server behind Nginx, a cloud load balancer, or a CDN

My opinion: set HSTS at the edge whenever possible, and also understand how your app behaves internally. If Nginx or your load balancer terminates TLS, that’s usually the best place to send the header. If you control the Node app directly and it serves HTTPS traffic to browsers, setting HSTS in Express is fine.

For most Apollo deployments, Express is still the easiest place to demonstrate the pattern.

Apollo Server with Express and Helmet

The cleanest way to add HSTS in an Express-based Apollo Server is helmet.

Here’s a complete example with Apollo Server 4 and Express:

import express from 'express';
import helmet from 'helmet';
import http from 'http';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import cors from 'cors';
import bodyParser from 'body-parser';

const typeDefs = `#graphql
  type Query {
    health: String!
  }
`;

const resolvers = {
  Query: {
    health: () => 'ok',
  },
};

const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
});

await apolloServer.start();

const app = express();

// If you're behind a reverse proxy or load balancer,
// trust the proxy so Express correctly understands req.secure
app.set('trust proxy', 1);

// Redirect HTTP to HTTPS if requests can still reach this app over HTTP
app.use((req, res, next) => {
  if (!req.secure) {
    return res.redirect(301, `https://${req.headers.host}${req.originalUrl}`);
  }
  next();
});

// Set HSTS only on HTTPS responses
app.use(
  helmet({
    hsts: {
      maxAge: 31536000, // 1 year in seconds
      includeSubDomains: true,
      preload: false,
    },
  })
);

app.use('/graphql', cors(), bodyParser.json(), expressMiddleware(apolloServer));

const httpServer = http.createServer(app);

httpServer.listen(4000, () => {
  console.log('GraphQL server running on port 4000');
});

That gets the basics right:

  • Redirects HTTP to HTTPS
  • Sends Strict-Transport-Security on secure responses
  • Uses a one-year max-age
  • Covers subdomains with includeSubDomains

If your GraphQL endpoint is only ever used by machine-to-machine clients, HSTS has limited value. But many “API-only” systems quietly end up browser-facing later. I’ve seen teams assume an endpoint was backend-only, then expose Apollo Sandbox or a web admin console on the same host. Better to decide intentionally.

The header you want in production

A solid production header usually looks like this:

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

If you want preload eligibility, use:

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

Be careful with preload. It’s a commitment, not a fun extra flag.

I usually roll HSTS out in stages:

  1. Start with a short max-age, like 300 seconds
  2. Verify HTTPS works everywhere
  3. Increase to a day: 86400
  4. Increase to a year: 31536000
  5. Only then consider includeSubDomains and preload

That staged approach saves pain when somebody forgot about dev.old-api.example.com still serving broken TLS.

Apollo standalone server example

If you’re using Apollo Server’s standalone mode, you don’t get Express middleware directly. In practice, if you care about headers and transport controls, I’d rather move to Express or put a proper reverse proxy in front.

Still, you can set HSTS if you wrap Apollo with your own server logic. For example:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

const typeDefs = `#graphql
  type Query {
    hello: String!
  }
`;

const resolvers = {
  Query: {
    hello: () => 'world',
  },
};

const server = new ApolloServer({ typeDefs, resolvers });

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req, res }) => {
    // HSTS is only meaningful over HTTPS in browser-facing scenarios.
    // In standalone mode, edge proxy config is usually the better option.
    if (res) {
      res.setHeader(
        'Strict-Transport-Security',
        'max-age=31536000; includeSubDomains'
      );
    }
    return {};
  },
});

console.log(`Server ready at ${url}`);

I wouldn’t call this my preferred setup. If you’re serious about transport security, put Apollo behind a reverse proxy or use Express where header behavior is explicit.

Setting HSTS at the reverse proxy

If Apollo runs behind Nginx, set HSTS there. That’s usually cleaner because TLS terminates there anyway.

Example Nginx config:

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

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

    ssl_certificate /path/to/fullchain.pem;
    ssl_certificate_key /path/to/privkey.pem;

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

    location /graphql {
        proxy_pass http://127.0.0.1:4000/graphql;
        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 matters because you want the header added consistently, including error responses. I’ve debugged setups where HSTS was missing on some 500s and redirects because of incomplete proxy config.

Common GraphQL-specific mistakes

1. Only protecting the app UI, not the API host

A team secures app.example.com but leaves api.example.com without HSTS because “the API is just JSON.” If the browser sends authenticated GraphQL requests there, it needs HSTS too.

2. Forgetting sandbox or landing page exposure

Apollo’s landing page or Sandbox often means your API is directly visited by browsers. That makes HSTS more relevant, not less.

3. Misconfigured proxy trust

If Express sits behind a load balancer and you forget this:

app.set('trust proxy', 1);

then req.secure may look false even for real HTTPS traffic. That breaks redirect logic and can cause loops or missing protections.

4. Sending HSTS over HTTP

Browsers ignore HSTS headers received over plain HTTP. You must deliver it on a valid HTTPS response.

5. Turning on includeSubDomains too early

This is where people hurt themselves. If you have old subdomains, internal tools, or forgotten environments, they all need proper HTTPS once browsers cache the policy.

Testing your Apollo HSTS setup

First, inspect the response manually:

curl -I https://api.example.com/graphql

You want to see:

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

Then verify HTTP redirects cleanly:

curl -I http://api.example.com/graphql

You should get a 301 or 308 redirect to HTTPS.

For a broader header check, run a scan with Headertest. It’s a quick way to catch missing or malformed security headers on your GraphQL host.

A practical production checklist

For an Apollo Server serving browser clients, this is the setup I want:

  • HTTPS enabled everywhere
  • HTTP redirected to HTTPS
  • HSTS set on the HTTPS host
  • max-age increased gradually
  • includeSubDomains only after verifying every subdomain
  • preload only if you fully understand the operational impact
  • Proxy trust configured correctly in Express
  • HSTS set at the edge if a reverse proxy or load balancer terminates TLS

A good Express config looks like this:

app.set('trust proxy', 1);

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

app.use(
  helmet.hsts({
    maxAge: 31536000,
    includeSubDomains: true,
    preload: false,
  })
);

That’s enough for most Apollo deployments.

HSTS is not the flashy part of GraphQL security, but it’s one of those controls that quietly removes an entire class of avoidable mistakes. I like defenses like that: boring, reliable, and hard for attackers to bypass once they’re in place.