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.
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:
- DevTools console: open it (F12 or Cmd+Opt+I) and reload. You should see
lodash loaded: trueand no CSP or integrity errors. - Break it on purpose: change one character in the
integrityhash, reload, and watch the console throw something likeFailed 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. - Break the CSP too: add
<script>console.log('hi')</script>inline toindex.html, reload, and you'll seeRefused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' https://cdn.jsdelivr.net". Remove it afterward. - 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.
- securityheaders.com: paste your
lhr.lifeURL 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: yourscript-srcdoesn't list the CDN's origin. Add it exactly, protocol included (https://cdn.jsdelivr.net, not justcdn.jsdelivr.netunless you also allow both schemes).- SRI hash never matches: you likely pinned
@latestinstead of an exact version. CDN files change over time; pinlodash@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 -Idirectly 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 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
No comments yet
Be the first to weigh in.