Hardening Our Own Front Door: Security Pass on the StudioSC Portfolio
Closing out Dependabot alerts, adding security headers and a CSP, and putting rate limiting + a honeypot in front of our contact form — durable rate limiting via Upstash Redis.
Hardening Our Own Front Door: Security Pass on the StudioSC Portfolio
Project Overview
Right after getting the portfolio to QA Verified and wiring up automated production deploys, we turned to a question every public-facing site eventually has to answer: what happens when the traffic hitting it isn't a visitor? This post covers the security and abuse-protection pass on studiosc.dev — a static-first Next.js site with no database and no auth, but a contact form that talks to a real inbox.
Scope
- Clear out npm audit / Dependabot vulnerabilities
- Add standard security headers and a Content Security Policy (CSP)
- Protect the contact form from scripted abuse without adding friction for real visitors
- Make the rate limiting durable across serverless instances — within a Vercel Hobby plan budget
Dependency Cleanup
npm audit was reporting 19 vulnerabilities locally (60 via GitHub's broader Dependabot scan), the bulk of them tracing back to Next.js itself. Upgrading next from 16.1.6 → 16.2.9 closed out a batch of critical advisories — HTTP request smuggling, a CSRF bypass, cache poisoning, and an XSS/DoS issue. One nested postcss@8.4.31 (bundled inside next) was still flagged after the upgrade; a flat overrides entry in package.json deduped it to the patched 8.5.x line.
Result: 0 vulnerabilities, verified with a full lint/type-check/build/e2e pass afterward (40/40 Playwright tests across five browsers).
Security Headers & CSP
Added a headers() block in next.config.ts applied to every route:
Content-Security-Policy— locksdefault-srcto'self', allow-lists Plausible and Vercel's Speed Insights endpoint forconnect-src, and setsframe-ancestors 'none',object-src 'none',base-uri 'self',form-action 'self'.X-Frame-Options: DENYandX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy— disables camera, microphone, and geolocation, none of which the site usesStrict-Transport-Security(HSTS) and the CSP'supgrade-insecure-requestsdirective
The WebKit gotcha
The last two — HSTS and upgrade-insecure-requests — turn out to be the kind of header Safari takes very literally, even on localhost. With both enabled unconditionally, all 8 WebKit and Mobile Safari Playwright tests started failing with navigation timeouts: Safari was rewriting every http://localhost:3000 request to https://, which doesn't exist in dev.
Fix was straightforward once diagnosed — gate both behind process.env.NODE_ENV === "production":
Confirmed via curl against both next dev and a production next start that the header sets differ as intended, then re-ran the full e2e suite: 40/40 passing again.
Contact Form: Rate Limiting + Honeypot
The contact form is the only write path on the site, so it's the realistic target for spam bots. Two layers, both transparent to real visitors:
-
Honeypot field — a
websitefield, visually hidden (off-screen,aria-hidden,tabIndex={-1}) and never shown to real users. Bots that auto-fill every input on a form trip it; the API route silently returns a fake "success" without sending an email, so the bot doesn't learn to adapt. -
Per-IP rate limiting — 5 requests per 10 minutes, returning
429 Too Many Requestsonce exceeded.
The first cut of the rate limiter was a simple in-memory Map, keyed by IP. That's a fine first line of defense, but Vercel serverless functions don't share memory across instances — under any real load, an attacker spread across cold starts could blow well past 5 requests.
Making it durable: Upstash Redis
We provisioned a free-tier Upstash Redis database through the Vercel Marketplace (Production environment only) and swapped the limiter to @upstash/ratelimit + @upstash/redis, using a sliding-window algorithm:
lib/rate-limit.ts checks for UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN at module load. If they're present (Production), it uses the Redis-backed sliding-window limiter — global across every instance and region. If they're absent (local dev, Preview deploys, which aren't connected to Upstash), it falls back to the original in-memory Map. Same 429 contract either way; route.ts didn't need to change beyond await-ing the now-async call.
Verification
Every change in this pass went through the same gate:
npm run lint && npm run type-check && npm run build && npm run test:e2e
Plus a production smoke test (next start with NODE_ENV=production):
- Confirmed CSP, HSTS, and the full security header set are present on real responses
- Confirmed a Mermaid-bearing blog page still renders correctly under the new CSP (
'unsafe-inline'/'unsafe-eval'keep client-side diagram rendering working) - Hit
/api/contact6 times in a row from the same IP — first several returned200, the rest429, exactly as designed
Why This Matters
None of this changes what the site does — it changes what happens when someone tries to make it do something it shouldn't. A portfolio site doesn't need enterprise-grade WAFs, but it does need: no known-vulnerable dependencies, sane default headers so browsers protect users automatically, and a contact form that can't be turned into a spam relay or a denial-of-service vector. All of that is now true of studiosc.dev, verified the same way we'd verify it for a client.
What's Next
This is the portfolio site's pass. The next post in this series covers carrying the same hardening — CSP, rate limiting, dependency hygiene — over to Central Command.
Related Project
StudioSC Portfolio
A modern Next.js portfolio showcasing our duo approach to software development and quality assurance
View Project Details