Hardening this site: from a D to an A+
When I finished building this portfolio, I ran it through an automated audit before deploying. One of the findings sat in the "medium priority" pile for a while: no security headers at all. No Content-Security-Policy, no X-Frame-Options, no Referrer-Policy, no X-Content-Type-Options, no Permissions-Policy. On top of that, the site was quietly exposing an X-Powered-By: Next.js header, telling anyone who looked exactly what framework and version to go searching for known vulnerabilities against.
I finally sat down and fixed it properly. Here's what that actually looked like, with real numbers.
Where it started
I ran the live site through two independent scanners: securityheaders.com and Mozilla's HTTP Observatory. Neither is opinionated marketing, they just check what's actually being sent in the HTTP response.
- securityheaders.com: D
- Mozilla Observatory: C-, 45/100
Both flagged the same core problem. Missing headers aren't just a formality, each one closes off a specific attack path:
- Content-Security-Policy tells the browser exactly which sources are allowed to run scripts, load styles, or fetch data. Without it, if an attacker ever finds a way to inject a script into the page (through a form, a comment field, anywhere user input touches the DOM), the browser will happily execute it.
- X-Frame-Options stops other sites from loading yours inside an invisible iframe and tricking visitors into clicking something they didn't mean to, this is called clickjacking.
- X-Content-Type-Options stops the browser from guessing a file's type based on its content instead of trusting the declared type, a small thing that closes off a class of MIME-sniffing attacks.
- Referrer-Policy controls how much information leaks to other sites when someone clicks a link away from yours.
Finding out how someone else would find this
Before fixing anything, it's worth understanding how an outsider would actually discover these gaps, since that's the real threat model, not a hypothetical one.
If your GitHub repo is public, anyone can run npm audit or pnpm audit directly against your lockfile and get a list of known CVEs in your dependencies. Tools like Snyk or Socket.dev do the same thing with a nicer interface. And even without touching your code, tools like Wappalyzer fingerprint your site just by reading the HTML and headers it serves, if you're leaking X-Powered-By: Next.js, you've just handed someone the exact framework and version to search for.
That last point is exactly why removing that header wasn't optional.
What I actually implemented
In next.config.mjs, I added a headers() block covering:
- A Content-Security-Policy scoped to what the site actually uses. Not a generic, copy-pasted policy, I went through the codebase to identify every external resource actually loaded (Resend for the contact form, the fonts, the chunks powering the three.js globe) and wrote the policy around exactly that. The first pass still had
unsafe-inlineand overly broad sources like a barehttps:inobject-src, which Mozilla Observatory rightly flagged as "implemented unsafely." I tightened it down: removedunsafe-inlineanddata:fromscript-src, and setobject-srctononeinstead of leaving it open. - X-Frame-Options: DENY
- Referrer-Policy: strict-origin-when-cross-origin
- X-Content-Type-Options: nosniff
- Permissions-Policy, disabling browser APIs the site has no use for (camera, microphone, geolocation)
- Cross-Origin-Resource-Policy: same-origin
poweredByHeader: false, removing the framework fingerprint entirely- The Secure flag on cookies, flagged separately by Observatory
I tested the whole site after each pass, both languages, the contact form, the 3D globe, since a CSP that's too strict will silently break things rather than throwing an obvious error. That's the real risk with hardening: making the site technically safer while quietly breaking it for real visitors.
Where it ended up
- securityheaders.com: A+
- Mozilla Observatory: A+
Same site, same functionality, just with the response headers actually doing their job. I also added a honeypot field and basic rate limiting on the contact form's API route, since an unprotected form is an easy target for spam bots regardless of how good the headers look.
None of this required rewriting the application. It's the kind of pass that's easy to skip because nothing about it is visually obvious, and I think that's exactly why it's worth doing properly. A site can look finished and still be leaving an unlocked door open.