Hardening a Production Web App: A Practical Security Audit

A few weeks ago I asked Claude Code to run a full production security audit on one of the sites I maintain — both the application code and the VPS it runs on — and then fix whatever it found. It found more than I expected, including one issue that had nothing to do with the site's code. Here's the exact playbook, with the real commands so that you can run the same audit against your own stack.
Why bother securing a personal blog?
It's tempting to think a personal site is low-value to an attacker. It isn't. It has an admin login backed by real credentials, it runs on a shared VPS alongside other projects, and a compromised low-value site is still a useful stepping stone — a place to host phishing pages, mine crypto, or pivot toward whatever else lives on the same box. Treat it with the same rigor you'd want from a client's production system.
1. Docker can silently punch a hole through your firewall
This was the most surprising finding, and it wasn't even in this app's own code. I run several projects on one VPS, each in Docker. UFW (Ubuntu's firewall) showed a clean default-deny policy with only 22/80/443 allowed. But two Postgres containers belonging to other projects were reachable from the open internet anyway.
The reason: Docker manages its own iptables rules to route traffic to published container ports, and those rules live in a chain that gets evaluated before UFW's own rules ever run. Publishing a port with a plain "5433:5432" in docker-compose.yml binds it to 0.0.0.0 — every network interface — regardless of what your firewall UI claims. UFW status can say "deny incoming" while the port is still wide open.
How to check if this is happening to you
# List everything actually listening, and on which interface
ss -tulnp | grep LISTEN
# For anything bound to 0.0.0.0 or ::, test it from a machine OUTSIDE the network
# (your firewall rules are meaningless if you only test from localhost)
nc -zv YOUR_SERVER_IP 5433If that nc call succeeds and the port has no business being public, you have the same problem I did.
The fix
The clean fix is to stop publishing the port to every interface — bind it to loopback only, the same way I already had on the app's own web port configured:
ports:
- "127.0.0.1:5433:5432"When you can't touch the other project's compose file right away, you can block it at the firewall layer instead. The key detail: Docker's DOCKER-USER chain sees packets after network address translation, so you have to match the container's internal IP and port — not the host-published port you'd expect.
# Find the container's internal IP
docker inspect <container_name> --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
# Block external (non-loopback) traffic to it — internal port is 5432, not 5433
iptables -I DOCKER-USER 1 -p tcp -d <container_ip> --dport 5432 ! -s 127.0.0.0/8 -j DROP
# Verify from an external machine
nc -zv YOUR_SERVER_IP 5433 # should now time outOne catch: a plain iptables -I only changes the running rule set — it disappears on reboot. If you're on Ubuntu with ufw, the durable place to put custom rules that touch the DOCKER-USER chain is /etc/ufw/after.rules, since that file is reapplied by ufw's own systemd service on every boot:
# Back up first
cp /etc/ufw/after.rules /etc/ufw/after.rules.bak
# Add these lines just before the final COMMIT line in the *filter section
-A DOCKER-USER -p tcp -d <container_ip> --dport 5432 ! -s 127.0.0.0/8 -j DROP
# Reload to apply and validate syntax
ufw reload2. Stop storing auth tokens in localStorage
This one's about the app itself. It used a fairly common pattern: sign in with Google, get back a JWT access token and refresh token, stash both in localStorage, attach the access token as an Authorization header on every API call. It works, and a lot of tutorials teach it this way. It's also a real liability.
localStorage is plain JavaScript-readable storage. Any script running on your page — including one an attacker manages to inject through a single XSS bug anywhere on the site — can read it and walk off with both tokens. A refresh token that's valid for a week means that one bug turns into a week of full account access, not just a momentary blip.
The fix is to move both tokens into httpOnly cookies, which JavaScript can never read regardless of what code is running on the page. In Express, that's just how you write the tokens back to the response:
function setAuthCookies(res, accessToken, refreshToken) {
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 15 * 60 * 1000,
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/auth', // only sent to auth endpoints, not every request
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}A few details worth calling out:
secure: true in production only — a Secure cookie is silently refused over plain HTTP, which will break your local dev environment if you hardcode it.
sameSite: 'lax' gives you CSRF protection for free on state-changing requests (POST/PUT/DELETE via fetch), without needing a separate CSRF token scheme.
Scoping the refresh cookie's
pathto just the auth routes means it's never sent on ordinary API calls — it only ever travels over the wire when it actually needs to.
You'll need the cookie-parser middleware to read cookies back out on the server (res.cookie itself needs no extra dependency, but req.cookies does):
npm install cookie-parserimport cookieParser from 'cookie-parser';
app.use(cookieParser());One wrinkle: an httpOnly cookie can't be read by your frontend either, which is normally exactly what you want — except the UI still needs to know "is this visitor logged in?" to decide whether to show a login button or a dashboard. The clean answer is a second, non-secret cookie that carries no token at all, just a flag:
res.cookie('logged_in', '1', {
httpOnly: false, // readable by JS on purpose
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 15 * 60 * 1000, // mirrors the access token's lifetime
});The frontend then just checks for its presence, never for a real token:
export function isLoggedIn() {
return document.cookie.split('; ').some((c) => c === 'logged_in=1');
}3. Never put tokens in a URL
The OAuth callback had a second problem stacked on top of the first: after Google redirected back, the app appended the tokens to the URL as query parameters (?token=...&refresh=...) and let the frontend read them out of window.location before storing them. URLs get logged — by your own nginx access logs, by your CDN, by any browser extension watching navigation, and they persist in browser history. A secret that only needs to exist inside an HTTP header now exists in half a dozen plaintext logs, indefinitely.
Since the fix above already has the server setting cookies directly, the redirect can carry nothing sensitive at all — the browser already has the cookies by the time it lands on the next page:
// Before: token in the URL
res.redirect(`${FRONTEND_URL}/admin?token=${accessToken}&refresh=${refreshToken}`);
// After: cookies set directly on the response, redirect carries nothing sensitive
setAuthCookies(res, accessToken, refreshToken);
res.redirect(`${FRONTEND_URL}/admin`);4. Validate uploads by what they actually are, not by what the client claims
The media upload endpoint accepted a file, checked its declared MIME type against an allowlist, and then saved it using the extension from the client-supplied original filename. Both of those are attacker-controlled. A malicious upload could set Content-Type to image/png while naming the file shell.html — it would pass the filter, and get saved and served back with a browser-executable extension straight from the app's own origin.
The fix removes the client's ability to choose the saved extension at all — it's derived server-side from a fixed allowlist keyed to the MIME type:
const ALLOWED_TYPES = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'application/pdf': '.pdf',
// no image/svg+xml — SVGs can embed <script> and execute on direct navigation
};
const upload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
filename: (_req, file, cb) => {
const unique = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
cb(null, `${unique}${ALLOWED_TYPES[file.mimetype] || ''}`);
},
}),
fileFilter: (_req, file, cb) => {
cb(null, Object.prototype.hasOwnProperty.call(ALLOWED_TYPES, file.mimetype));
},
});SVG is worth calling out on its own: it's a valid image format, but it's also an XML document that can carry an embedded <script> tag, and browsers will run it if the file is ever opened directly rather than embedded as an <img>. Unless you have a specific reason to accept SVG uploads, leave it off the list.
5. Sanitize any HTML you render, even from "trusted" sources
Blog content on this site is authored as structured Tiptap JSON and converted to HTML on the server, then injected into the page. Editor and admin accounts are the only ones who can write it — but "only trusted users can write it" is exactly the kind of assumption that becomes false the moment one of those accounts is ever compromised, and a compromised account can then plant content that runs for every single visitor to the site, not just for that account.
Adding a sanitization pass costs one line and closes that gap regardless of how the content was produced:
npm install isomorphic-dompurifyimport DOMPurify from 'isomorphic-dompurify';
export function renderContent(tiptapJson) {
const html = generateHTML(tiptapJson, extensions);
return DOMPurify.sanitize(html); // defense-in-depth, regardless of the source
}6. Patch your dependencies — actually check, don't assume
npm audit against this project turned up a Next.js version with several disclosed CVEs, an outdated nodemailer with multiple advisories, and a couple of moderate-severity transitive issues. None of it was surprising once I looked — it just hadn't been looked at.
# See what's actually vulnerable in your installed tree
npm audit
# Apply fixes that don't require a breaking version bump
npm audit fix
# For anything left, check whether the "breaking" bump is really just a
# patch/minor release npm is being conservative about, then bump it
# explicitly in package.json and reinstall
npm install
npm audit # confirm 0 remainingA couple of things I explicitly did not force through: a major version bump of a rich-text editor library with real breaking API changes, and removing unsafe-inline from the Content-Security-Policy (more on that below). Both are worth doing, but not blindly, under time pressure, without a way to fully test the result. A security fix that breaks the site is still an incident.
7. Don't let two layers fight over the same headers
This app sits behind an nginx reverse proxy on the VPS, and both nginx and the Next.js app were independently setting X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy. Functionally harmless — browsers just take the first or merge them — but it's noise, it doubles response header size for no reason, and the two copies had drifted to list Permissions-Policy directives in a different order, which is exactly the kind of inconsistency that makes you stop trusting your own configuration.
Pick one layer as the source of truth. I kept the always-applied ones in nginx, and left only the ones nginx doesn't set (Content-Security-Policy, Strict-Transport-Security) in the app itself:
const isProd = process.env.NODE_ENV === 'production';
const securityHeaders = [
{ key: 'Content-Security-Policy', value: csp },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
// nginx already sets these in front of the app in production —
// only duplicate them here for local dev, where there's no nginx in front.
...(isProd ? [] : [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
]),
];I also found a leftover http://localhost:4000 baked into the production Content-Security-Policy — harmless in practice, since nothing there actually resolves in production, but it's a tell that dev-only configuration leaked into a prod build, and it's worth grepping for anytime you promote a config file across environments.
A quick checklist, if you want to run this against your own stack
From outside the network, port-scan every port your server has open — not just the ones you meant to open.
If you run Docker behind ufw/iptables, confirm published container ports are actually blocked externally when they should be — don't trust the firewall UI alone.
Auth tokens go in httpOnly cookies, never in localStorage or the URL.
File uploads: derive the saved extension from a server-side allowlist, never from client-supplied data.
Sanitize any HTML you inject into the page, even content from accounts you trust today.
Run npm audit on a schedule, not just when something breaks.
Pick one layer (proxy or app) to own each security header, and check for stale environment-specific values in shared config.
None of this is exotic. It's the same handful of checks that apply to almost any small web app, and running through them methodically turned up real, exploitable gaps on a site I'd have otherwise assumed was fine simply because nothing had gone wrong yet.
Comments
Comments disabled — configure NEXT_PUBLIC_GISCUS_* env vars.