How I secured this site
A static site, a CSP with no inline code, DNSSEC, DMARC and a CI pipeline that stops me from breaking things myself. What I did, where I tripped up, and why there's almost no JavaScript here.
Czytaj po polskuThe portfolio of someone who wants to work in security is part of the CV itself. A security recruiter can open DevTools, check the headers or run the address through a scanner. I decided this site should demonstrate what I claim, not just talk about it.
First: as little attack surface as possible
The simplest defence is having nothing to attack. This site is fully static: no application server, database, login, forms or API. It’s just HTML, CSS and fonts, built once and served from a CDN.
There are also no cookies, trackers, analytics, third-party scripts or font CDNs. The fonts are served from the same domain, so a visitor’s browser never talks to anyone but me. No cookies means no cookie banner either.
I started the project in Next.js but quickly switched to Astro. Next.js static export injects inline scripts into every page, so the CSP would need 'unsafe-inline' or juggling hashes on every build. Astro ships zero JavaScript to the browser by default.
Content Security Policy
CSP tells the browser where a page is allowed to load resources from. Everything else is blocked, even if someone managed to inject code into the HTML. My policy:
Content-Security-Policy:
default-src 'none';
script-src 'self';
style-src 'self';
img-src 'self';
font-src 'self';
connect-src 'none';
object-src 'none';
base-uri 'none';
form-action 'none';
frame-ancestors 'none';
upgrade-insecure-requests
It starts from default-src 'none' (“nothing is allowed”) and only permits what the site actually needs, and only from its own origin. There is no 'unsafe-inline' and no 'unsafe-eval', so even an injected <script> tag or onclick attribute won’t run. form-action 'none' and base-uri 'none' close two less obvious doors: redirecting form submissions and hijacking the base URL for links. frame-ancestors 'none' blocks framing, which prevents clickjacking.
At first I had script-src 'none', because the site had no JavaScript at all. Then I added a mobile menu built on the native Popover API, which works without scripts but doesn’t close after you tap a link. For that I added a single menu.js file (7 lines) and changed the policy to 'self'. Without JS the menu still works, you just close it by hand.
Where I tripped up
A strict CSP quickly exposes the things your tools do behind your back:
- A font as a
data:URI. Vite inlines files under 4 KB straight into the CSS as base64 by default. One small font subset got inlined andfont-src 'self'blocked it. I could have addeddata:to the policy, but I setassetsInlineLimit: 0instead so every file stays separate. - Syntax highlighting. Astro’s default highlighter (Shiki) colours code with
style="..."attributes, whichstyle-src 'self'blocks. I switched to Prism, which uses CSS classes. - “CSP blocks eval” in the console. That wasn’t my code but browser extensions injecting scripts into every page. The CSP did exactly what it should.
The other headers
| Header | Value | Why |
|---|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
HTTPS only for 2 years. The .dev TLD is on the HSTS preload list anyway. |
X-Content-Type-Options |
nosniff |
The browser doesn’t guess file types. |
X-Frame-Options |
DENY |
Blocks framing in older browsers (newer ones use frame-ancestors). |
Referrer-Policy |
strict-origin-when-cross-origin |
Other sites don’t get the full URL the visitor came from. |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), … |
The site opts out of camera, microphone, location and payment access up front. |
Cross-Origin-Opener-Policy |
same-origin |
Isolates the window from pages opened on other origins. |
Cross-Origin-Embedder-Policy |
require-corp |
Only loads resources that explicitly allow it. |
Cross-Origin-Resource-Policy |
same-origin |
My files can’t be embedded on other sites. |
DNS and email
Security doesn’t stop at HTTP. Someone who hijacks DNS or spoofs the domain in email bypasses every header.
- DNSSEC is enabled: DNS answers for the domain are signed and the DS record lives in the
.devregistry. A validating resolver won’t accept a forged record. - SPF, DKIM and DMARC with a
p=quarantinepolicy: mail servers know which servers may send mail for my domain, and forgeries end up in spam. - Registrar transfer lock, so nobody can quietly move the domain elsewhere.
/.well-known/security.txtper RFC 9116: if someone finds a problem, they know where to report it.
Supply chain
Most of the code that ends up in the build isn’t mine, so I keep an eye on dependencies:
- package versions are pinned (
--save-exact) and locked inpackage-lock.json, and I pick versions that have been out for at least a few days, not ones from an hour ago; - CI runs on every push: a clean install (
npm ci --ignore-scripts),npm audit(fails on high/critical vulnerabilities) and a build; - the same workflow inspects the built HTML and CSS: an inline
<script>,<style>, astyle=attribute or adata:URI in the CSS fails the build. That’s the font bug from above, caught automatically; - GitHub Actions are pinned to specific commit SHAs rather than tags, so nobody can swap the action’s code under the same version number. The workflow only has read access to the repo;
- Dependabot proposes package and action updates every week.
Privacy
The site only shows what I want to show: my name, city and an email address on my own domain. No phone number, date of birth or home address. Before publishing the photo and CV I strip their metadata: EXIF with GPS and phone model, and author and file paths in the PDF.
Results
- Mozilla HTTP Observatory: A+, 155 points, 12/12 tests
- SSL Labs: A+ / A, depending on which Vercel edge server the test hits.
What I learned
The biggest lesson wasn’t setting the headers, it was understanding what breaks them. Every CSP exception you’re tempted to add is a sign that some tool is doing something you can’t see. It’s better to reconfigure the tool than loosen the policy. And a check that stops a problem from coming back is worth more than a one-off fix.