Hardening Notes
Building a portfolio site as a security artifact
This site exists to do two jobs at once: route people to my work, and be a piece of that work. A security practitioner’s portfolio that scores a B on a header scan undercuts its own message. So I treated the site as a system to be hardened and measured, and I wrote down every decision — including the ones where I chose not to act, and why. That last part matters more than the green checkmarks: knowing which risks to accept, and documenting the reasoning, is the job.
Threat model and definition of done
This is a fully public, static, brand-and-portfolio site. It holds no user accounts, no secrets, no authenticated endpoints, and no personal data beyond a public contact address. The realistic threats are therefore: cross-site scripting via injected content, clickjacking, transport downgrade or interception, MIME-sniffing, feature abuse, and — because the domain sends real email — spoofing of @threatlenssec.com.
“Done” was defined up front as measurable targets, not vibes:
- A+ on securityheaders.com
- A+ on the Mozilla HTTP Observatory
- Lighthouse ≥ 95 across Performance, Accessibility, Best Practices, and SEO
- Full email authentication: SPF, DKIM, and DMARC
Stack choice: attack surface first
The single biggest security decision was made before a line of code: static site, no CMS. I built with Astro compiled to static HTML and hosted on Cloudflare Pages. The deliberate rejection was WordPress — not out of dislike, but because a database-backed CMS with a plugin ecosystem is a standing attack surface (authenticated admin panel, server-side execution, third-party plugin CVEs, a permanent patch treadmill).
| Property | Static (chosen) | CMS (rejected) |
|---|---|---|
| Server runtime | None — just files at the edge | App/DB process to exploit |
| Admin login | None exposed | Public, brute-forceable |
| Runtime dependency CVEs | Zero (nothing runs) | Core + every plugin |
| Patch burden | Rebuild from source | Continuous |
You cannot exploit a server that is not running. A static site collapses most of the web’s common attack classes down to a single question: what headers does the edge send, and what is in the HTML.
Content Security Policy: the A+ maker
The hardest grade to earn — and the one most sites fail — is a strict CSP. My policy denies everything by default and re-allows only same-origin resources, with no unsafe-inline and no unsafe-eval anywhere:
Content-Security-Policy:
default-src 'none';
script-src 'self'; style-src 'self';
img-src 'self' data:; font-src 'self';
connect-src 'self'; manifest-src 'self';
base-uri 'none'; form-action 'none';
frame-ancestors 'none'; object-src 'none';
upgrade-insecure-requestsdefault-src 'none' is the posture: nothing loads unless a later directive explicitly permits it. The reason most sites fall back to unsafe-inline is convenience — inline style blocks and script tags “just work.” But unsafe-inline is exactly what makes injected markup executable, so allowing it defeats the purpose of CSP against XSS.
To avoid it, I engineered the build to emit zero inline anything. Astro is configured with inlineStylesheets: 'never' so all CSS ships as an external, hashed file under style-src 'self'; there is no client-side JavaScript at all. Every build runs an automated check that greps the compiled HTML for inline scripts, inline styles, and style= attributes, and fails if any appear. The tradeoff is real — you give up the convenience of dropping in an inline snippet — but the payoff is a CSP with no holes.
Transport security (HSTS)
Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadTwo years, all subdomains, and the preload directive. This forces HTTPS and defends against SSL-stripping downgrade attacks. One deliberate boundary: sending the preload directive is not the same as submitting the domain to the browser preload list at hstspreload.org. I have not submitted, and that is intentional — preload-list inclusion is a slow-to-reverse commitment that requires every current and future subdomain to serve valid HTTPS indefinitely. The header is reversible; the list is effectively not. I will submit only once the subdomain story is settled. Status: accepted, pending.
Clickjacking, sniffing, referrer, and features
X-Frame-Options: DENYplus CSPframe-ancestors 'none'— the page cannot be framed, which defeats clickjacking.X-Content-Type-Options: nosniff— browsers must honor declared content types instead of guessing, closing MIME-confusion attacks.Referrer-Policy: strict-origin-when-cross-origin— limits referrer leakage to other sites.Permissions-Policy— every powerful feature (camera, microphone, geolocation, USB, and the rest) is denied, because a brochure site needs none of them. If the page is ever compromised, it still cannot reach for the webcam.Cross-Origin-Opener-PolicyandCross-Origin-Resource-Policyset tosame-originfor cross-origin isolation.
Email authentication: SPF, DKIM, DMARC
The domain sends real mail through Google Workspace, so unauthenticated mail is a spoofing risk. SPF and DKIM were already provisioned by Workspace; the gap was DMARC, which ties them together and tells receivers what to do on failure.
- SPF:
v=spf1 include:_spf.google.com ~all— a single record (duplicates silently break SPF), authorizing Google and soft-failing everything else. - DKIM: a 2048-bit RSA key published at
google._domainkey, so outbound mail is cryptographically signed. - DMARC: deployed in monitor mode first —
v=DMARC1; p=none; rua=....
The DMARC rollout is deliberately staged, because jumping straight to enforcement is how people silently destroy their own email deliverability:
| Stage | Policy | Gate to advance |
|---|---|---|
| 1. Monitor | p=none | Collect aggregate reports; confirm legitimate mail aligns on SPF/DKIM |
| 2. Quarantine | p=quarantine | Reports clean over a sustained window |
| 3. Reject | p=reject | No legitimate sources failing |
You enforce based on evidence from the reports, not on hope. That discipline is the entire point.
Supply chain and delivery
The site deploys through a Git-connected pipeline: a push to main triggers Cloudflare to build from source and publish to the edge. Several choices there are security choices:
- Least-privilege integrations. Both the GitHub CLI and the Cloudflare Pages GitHub app were scoped to only this one repository, not the whole account. A compromise of either is contained to a single public repo.
- Reproducible builds. The Node version is pinned via
NODE_VERSIONand dependencies are locked withpackage-lock.json, so the edge builds the exact toolchain I verified locally — not whatever drifts in later. - Verified tooling. Local tools were installed through a signed package manager that validates installer hashes before execution — integrity checking on the supply chain, not blind downloads.
- Commit OPSEC. Commits are authored with GitHub’s privacy (noreply) address, so my real email is not harvestable from public commit history.
- Secret hygiene. The repo is public by design; a
.gitignoreblocks the usual offenders (.env, keys), and no real secret ever enters it. Deploy configuration lives in the host, not the source.
Verification evidence
Claims are worth what you can prove. Current measured results:
| Check | Result |
|---|---|
| securityheaders.com | A+ — all six headers present |
| Mozilla HTTP Observatory | A+ — 140/100, 12/12 tests |
| Lighthouse (Performance) | 100 |
| Lighthouse (Accessibility) | 100 |
| Lighthouse (Best Practices) | 100 |
| Lighthouse (SEO) | 100 |
| Email authentication | SPF + DKIM + DMARC (DMARC in monitoring) |
Accepted risks and deferred work
Mature security is not a wall of green — it is a documented set of decisions. These are the items I know about and chose to accept or defer, with the reasoning on record so the choice is auditable later.
| Item | Decision & rationale | Status |
|---|---|---|
access-control-allow-origin: * | Set by the CDN by default. Acceptable here because the site is fully public with no credentialed endpoints and nothing non-public to read — there is no resource to protect behind CORS. | Accepted |
| HSTS preload-list submission | The header carries the preload directive, but the domain is not on the browser list. Near-irreversible commitment; deferred until the subdomain story is final. | Deferred |
| Cross-Origin-Embedder-Policy | Not set. Would complete full cross-origin isolation for bonus credit; safe to add since the site is 100% same-origin. Pure upside, not yet applied. | Planned |
| DMARC enforcement | Currently p=none. Advancing to quarantine, then reject, only after aggregate reports confirm legitimate mail aligns. | In progress |
What this demonstrates
The point was never the letter grades — those are a side effect. The point is the method: define measurable targets, engineer toward them instead of bolting controls on afterward, verify with independent tools, and document every decision including the risks I accepted and why. That is how I work a system, and this site is the smallest honest proof of it.