Skip to content
Security Beginner Tutorial

Lock Down a Web App with CSP, SRI, and Security Headers

Ship an Express app with a strict Content-Security-Policy, subresource integrity checks, and a full set of hardened HTTP headers, then grade your work with a real online scanner.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Jul 12, 2026 · 9 min read
Lock Down a Web App with CSP, SRI, and Security Headers

What you'll build

A minimal Express app serving a page that loads a CDN script, with a locked-down CSP, SRI-verified script tags, and a full set of hardened response headers. You'll expose it publicly for a few minutes and grade it on securityheaders.com.

Prerequisites

  • Node.js 18+ and npm (check with node -v)
  • A terminal with an SSH client. macOS and Linux have one built in. Windows 10+ has OpenSSH built into PowerShell.
  • Chrome or Firefox with DevTools
  • No accounts needed. localhost.run and securityheaders.com are both free, no signup for what we're doing here.

1. Scaffold the app

mkdir csp-demo && cd csp-demo
npm init -y
npm install express helmet
mkdir public

Create public/app.js:

console.log('lodash loaded:', typeof _ !== 'undefined');

Create public/index.html. Leave the integrity value as a placeholder for now, you'll fill it in during step 4.

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>CSP demo</title>
</head>
<body>
  <h1>CSP + SRI demo</h1>
  <script
    src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"
    integrity="sha384-REPLACE_ME"
    crossorigin="anonymous"></script>
  <script src="/app.js"></script>
</body>
</html>

2. Add security headers with Helmet

Create server.js. Helmet sets a good baseline (X-Content-Type-Options, X-Frame-Options, a default HSTS header, referrer policy) but you still need to customize CSP for your own site, and Helmet doesn't ship Permissions-Policy by default, so we add that ourselves.

const express = require('express');
const helmet = require('helmet');
const app = express();

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", 'https://cdn.jsdelivr.net'],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", 'data:'],
        objectSrc: ["'none'"],
        baseUri: ["'self'"],
        frameAncestors: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    // Cross-Origin-Embedder-Policy requires every cross-origin resource to
    // send CORP/CORS headers. jsdelivr's script tag doesn't need that
    // level of isolation here, so we turn it off to avoid blocking it.
    crossOriginEmbedderPolicy: false,
  })
);

app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', 'geolocation=(), camera=(), microphone=()');
  next();
});

app.use(express.static('public'));

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

3. Understand the CSP you just wrote

Directive What it does here
default-src 'self' Fallback: only load resources from your own origin unless overridden below
script-src 'self' https://cdn.jsdelivr.net Scripts only from your server or that one CDN, no inline <script> tags
object-src 'none' Blocks Flash/plugins, a classic XSS vector
frame-ancestors 'none' Nobody can iframe your site (replaces the old X-Frame-Options)
upgrade-insecure-requests Rewrites accidental http:// sub-resource links to https://

Notice script-src has no 'unsafe-inline'. That's the whole point: an attacker who injects <script>evil()</script> via a form field gets blocked by the browser, not by your sanitization code (which might have a bug).

4. Generate a real SRI hash

SRI proves the file the CDN serves you today is byte-for-byte the file you tested against. Don't trust a copy-pasted hash from a blog post, generate it yourself.

First download the exact file:

curl -s https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js -o lodash.min.js

Then hash it. Skip openssl here, it's not installed by default on Windows even though OpenSSH is. Use Node instead, since you already have it:

node -e "console.log('sha384-' + require('crypto').createHash('sha384').update(require('fs').readFileSync('lodash.min.js')).digest('base64'))"

Copy the output (it already includes the sha384- prefix) and drop it into the integrity attribute in public/index.html. Delete the downloaded lodash.min.js, you don't need to serve it yourself.

5. Run it and expose it

node server.js

Check the headers locally first:

curl -I http://localhost:3000/

You should see Content-Security-Policy, X-Content-Type-Options, Strict-Transport-Security, and Permissions-Policy in the response.

Now expose it so securityheaders.com can reach it, using localhost.run's SSH tunnel (no install, no account):

ssh -R 80:localhost:3000 nokey@localhost.run

SSH will print a forwarding URL, something like https://abcd1234.lhr.life. That's your public HTTPS endpoint for the next few minutes, pointing straight at your local dev server. Leave that terminal window open.

6. Verify it works

Open the lhr.life URL in your browser. Then:

  1. DevTools console: open it (F12 or Cmd+Opt+I) and reload. You should see lodash loaded: true and no CSP or integrity errors.
  2. Break it on purpose: change one character in the integrity hash, reload, and watch the console throw something like Failed to find a valid digest... The resource has been blocked. That's SRI doing its job, a tampered CDN file gets refused, not silently executed.
  3. Break the CSP too: add <script>console.log('hi')</script> inline to index.html, reload, and you'll see Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' https://cdn.jsdelivr.net". Remove it afterward.
  4. Network tab: click the document request, open the Headers panel, confirm all your security headers are present on the response, not just added client-side.
  5. securityheaders.com: paste your lhr.life URL into the scan box. You're aiming for an A or A+. HSTS may show a warning since it only takes full effect after repeated HTTPS visits, that's expected on a fresh tunnel domain and not a bug in your config.

Troubleshooting

  • Refused to load the script... violates CSP: your script-src doesn't list the CDN's origin. Add it exactly, protocol included (https://cdn.jsdelivr.net, not just cdn.jsdelivr.net unless you also allow both schemes).
  • SRI hash never matches: you likely pinned @latest instead of an exact version. CDN files change over time; pin lodash@4.17.21 (or whatever exact version) so the bytes, and therefore the hash, never shift under you.
  • ssh: connect to host localhost.run port 22: Connection refused: check you're not behind a firewall blocking outbound port 22, or try again, localhost.run occasionally rate-limits free connections. ngrok is a solid fallback if you need something more permanent.
  • Headers missing behind a reverse proxy (nginx, Cloudflare, etc.): confirm the proxy isn't stripping or overwriting headers your app sets. curl -I directly against the app (bypassing the proxy) tells you whether the problem is your code or your infrastructure.

Next steps

Read MDN's CSP reference for every directive, look at the OWASP Secure Headers Project for a fuller checklist, and once this is stable in production, wire up report-to/report-uri so CSP violations get reported instead of just silently blocked. Swapping 'unsafe-inline' for nonces or hashes in style-src is the natural next hardening step once your inline styles are under control.

Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading