If you run GraphQL Yoga in production without HSTS, you’re trusting every first insecure hop a browser might take before it reaches your HTTPS endpoint. That’s a bad bet.

HSTS, or HTTP Strict Transport Security, tells browsers: “stop trying plain HTTP for this site; use HTTPS only.” For a GraphQL API, that matters more than people think. GraphQL often carries auth tokens, session cookies, admin operations, and introspection data you really don’t want exposed over downgraded or intercepted connections.

GraphQL Yoga doesn’t magically solve this for you. You need to set the header deliberately, and you need to do it in the right place.

What HSTS actually does

The browser sees a response header like this:

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

That means:

  • max-age=31536000: remember for 1 year
  • includeSubDomains: apply to all subdomains too

Once a browser receives that over a valid HTTPS response, it will refuse to use HTTP for that host until the policy expires.

A few practical implications:

  • HSTS only works after the browser has seen it once, unless you use preload.
  • If your API is available at api.example.com, HSTS applies to that host, not automatically to example.com, unless configured there too.
  • If you serve GraphQL in a browser context with cookies, HSTS is a very good idea.
  • If your GraphQL endpoint is only machine-to-machine, HSTS still helps for browser-based tooling like GraphiQL, internal admin panels, and developer consoles.

Where to set HSTS with Yoga

GraphQL Yoga usually runs in one of these setups:

  • Directly in Node’s http/https server
  • Behind Express/Fastify/Hono
  • Behind a reverse proxy like Nginx, Caddy, Cloudflare, or an ingress controller

My rule: set HSTS at the edge if you can, and also understand how to set it in the app. The edge is usually more reliable because every response gets covered, not just your GraphQL handler.

Still, app-level examples are useful, especially if you deploy Yoga on serverless or minimal Node infrastructure.

A basic GraphQL Yoga server with HSTS

Here’s a minimal Yoga server in Node:

import { createServer } from 'node:http'
import { createYoga, createSchema } from 'graphql-yoga'

const yoga = createYoga({
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type Query {
        health: String!
      }
    `,
    resolvers: {
      Query: {
        health: () => 'ok',
      },
    },
  }),
})

const server = createServer(async (req, res) => {
  const response = await yoga.handleNodeRequest(req)

  // Only set HSTS when the request is actually HTTPS.
  // If you're behind a proxy, see the proxy section below.
  const isHttps =
    (req.socket as any).encrypted ||
    req.headers['x-forwarded-proto'] === 'https'

  if (isHttps) {
    response.headers.set(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains'
    )
  }

  res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
  res.end(await response.text())
})

server.listen(4000, () => {
  console.log('GraphQL Yoga running on http://localhost:4000/graphql')
})

That works, but I wouldn’t ship it exactly like this without thinking about proxies and local development.

Don’t enable HSTS blindly in development

HSTS can be annoying in local environments. If you set it on localhost over HTTPS and later switch back to HTTP, your browser may keep forcing HTTPS. That’s not a security issue; it’s just annoying enough to waste half an hour.

I usually gate it by environment:

const isProduction = process.env.NODE_ENV === 'production'

if (isProduction && isHttps) {
  response.headers.set(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains'
  )
}

If you need staging coverage, be careful. includeSubDomains on a shared staging parent domain can break things if some subdomains don’t support HTTPS yet.

Using Yoga with Express and Helmet

If your Yoga app runs on Express, use helmet. It’s the easiest sane option.

import express from 'express'
import helmet from 'helmet'
import { createYoga, createSchema } from 'graphql-yoga'

const app = express()

app.set('trust proxy', true)

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

const yoga = createYoga({
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type Query {
        health: String!
      }
    `,
    resolvers: {
      Query: {
        health: () => 'ok',
      },
    },
  }),
})

app.use('/graphql', yoga)

app.listen(4000, () => {
  console.log('Server running on port 4000')
})

A few things I like about this setup:

  • helmet handles the header consistently
  • trust proxy helps Express understand HTTPS when TLS terminates upstream
  • every route gets the header, not just /graphql

If you’re checking your deployment, run a free scan with HeaderTest. It’s a quick way to catch missing HSTS or a weak policy after proxy changes.

Reverse proxy setup: the part people forget

A lot of Yoga deployments terminate TLS at a proxy, not in Node itself. In that setup, your app may only ever see plain HTTP from the proxy. If you decide whether to emit HSTS based on req.socket.encrypted, you’ll get it wrong.

You have two decent options:

Option 1: Set HSTS at the proxy

This is my preference.

Nginx

server {
    listen 443 ssl http2;
    server_name api.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 /graphql {
        proxy_pass http://127.0.0.1:4000;
        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 non-200 responses may miss the header.

Caddy

api.example.com {
    reverse_proxy 127.0.0.1:4000
    header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}

Option 2: Trust forwarded headers in the app

If you can’t set headers at the edge, inspect X-Forwarded-Proto and only trust it when requests come through infrastructure you control.

const forwardedProto = req.headers['x-forwarded-proto']
const isHttps =
  (req.socket as any).encrypted ||
  forwardedProto === 'https'

That’s fine behind your own load balancer. It’s not fine if random clients can hit the app directly and spoof forwarded headers.

Picking a good HSTS policy

For most production GraphQL APIs:

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

That’s a solid default.

What about preload?

Preload means browsers ship your domain in a built-in HTTPS-only list. To qualify, you typically need:

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

I’m cautious with preload. It’s great for mature domains with complete HTTPS coverage. It’s a pain if you have legacy subdomains, weird vendor endpoints, or forgotten internal services.

For a dedicated API hostname like api.example.com, preload usually doesn’t make sense unless the parent domain strategy is already clean and deliberate. Don’t add preload because it looks “more secure” in a checklist.

Cookies, GraphiQL, and browser clients

If your Yoga server powers browser sessions, HSTS should be paired with secure cookies:

Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax

HSTS does not replace Secure cookies. Use both.

This matters for:

  • GraphiQL or Yoga’s landing page in production
  • internal admin dashboards talking to GraphQL
  • SPA apps using cookie auth
  • OAuth callback flows landing on the same domain

If your GraphQL endpoint is public and used by browser-based clients, don’t leave this half-configured.

A clean production-ready Yoga example

Here’s a more realistic setup with environment checks:

import { createServer } from 'node:http'
import { createYoga, createSchema } from 'graphql-yoga'

const isProduction = process.env.NODE_ENV === 'production'
const trustProxy = process.env.TRUST_PROXY === 'true'

const yoga = createYoga({
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type Query {
        health: String!
      }
    `,
    resolvers: {
      Query: {
        health: () => 'ok',
      },
    },
  }),
})

const server = createServer(async (req, res) => {
  const response = await yoga.handleNodeRequest(req)

  const forwardedProto = req.headers['x-forwarded-proto']
  const isHttps =
    Boolean((req.socket as any).encrypted) ||
    (trustProxy && forwardedProto === 'https')

  if (isProduction && isHttps) {
    response.headers.set(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains'
    )
  }

  res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
  res.end(await response.text())
})

server.listen(4000)

That’s simple and safe enough for many deployments.

Common mistakes

I’ve seen these a lot:

Sending HSTS over HTTP

Browsers ignore HSTS received over plain HTTP. You don’t get any protection from that.

Setting it only on /graphql

If users ever touch /, /graphiql, auth routes, or callback endpoints on the same host, they should get the same protection.

Using includeSubDomains too early

If old-admin.example.com still exists and doesn’t support HTTPS, you just broke it for HSTS-enabled browsers.

Trusting spoofed proxy headers

If the app is directly reachable, a client can fake X-Forwarded-Proto: https. Lock down network paths or set HSTS at the proxy.

Turning on preload casually

Removing a preloaded domain is slow and painful. Treat it like a one-way door.

How to verify it

After deployment:

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

You want to see:

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

Also test error responses, redirects, and any non-GraphQL routes on the same host. If HSTS disappears on 301s, 500s, or auth endpoints, your setup is incomplete.

For GraphQL Yoga, HSTS is not complicated. The hard part is deploying it where it actually sticks: at the HTTPS edge, across all responses, without breaking subdomains you forgot existed. That’s the real work. Once you handle that, the header itself is easy.