Skip to content
All posts

· 2 min read

Hardening a Node.js frontend for government services

Security headers, caching rules, sessions and templating defaults I reach for when a Node/Express layer sits in front of sensitive data.

  • Node.js
  • Express
  • Security
  • GOV.UK

A lot of my recent work has been on Node.js layers that sit between citizens or court staff and a set of backend microservices. At HMCTS that meant consolidating several services behind one Express and Nunjucks application, deployed to Azure. The frontend isn’t where the business rules live, but it is where most of the security sharp edges are. Here is the checklist I now apply on day one.

1. Security headers with Helmet, including a real CSP

helmet() with defaults is a good start, but the Content Security Policy is the header that actually earns its keep. Most GOV.UK Frontend apps need a little inline script (document.body.className += ' js-enabled'), so generate a nonce per request rather than reaching for 'unsafe-inline':

import crypto from 'node:crypto';
import express from 'express';
import helmet from 'helmet';

const app = express();

app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
  next();
});

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
        imgSrc: ["'self'", 'data:'],
      },
    },
  }),
);

Then in the Nunjucks layout: <script nonce="{{ cspNonce }}">…</script>.

2. Never let a cache hold personal data

Pages that show someone’s case, claim or passport application should never be stored by the browser, a proxy or a CDN. That’s a one-line middleware applied to every authenticated route:

const noStore = (req, res, next) => {
  res.set('Cache-Control', 'no-store, max-age=0');
  next();
};

app.use('/case', requireAuth, noStore, caseRoutes);

Without it, pressing Back after signing out on a shared computer can reveal the previous page.

3. Sessions that behave behind a load balancer

On Kubernetes there are always several pods, so in-memory sessions are out. Back them with Redis, and make sure the cookie flags are right when TLS terminates at the ingress:

app.set('trust proxy', 1); // TLS ends at the ingress, so trust X-Forwarded-Proto

app.use(
  session({
    store: new RedisStore({ client: redis }),
    secret: process.env.SESSION_SECRET,
    name: '__Host-session',
    resave: false,
    saveUninitialized: false,
    cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 60 * 1000 },
  }),
);

The __Host- prefix makes the browser reject the cookie unless it is Secure, has no Domain and has Path=/, which is a free extra safety net.

4. Authentication belongs to a library, not to you

For OpenID Connect, use a certified client such as openid-client and let it handle discovery, PKCE, state and nonce validation, and token signature checks. The application code should only deal with “who is this user and what can they do”. Keep tokens server-side in the session; the browser only ever sees the session cookie.

5. Template defaults that fail safe

Configure Nunjucks with autoescape: true (the default, but make it explicit) and treat every | safe filter as a code-review flag. Pair that with server-side validation on every form post: client-side validation is a usability feature, not a security control.

6. Make it testable

Each of these lends itself to a tiny integration test: request a page, then assert the headers. supertest with Jest or Vitest takes a few lines per check, and it stops a refactor from quietly removing no-store from a route.

None of this is glamorous, but it’s the difference between a service that passes its IT health check first time and one that spends a sprint on remediation.