Why a Portfolio Is Worth Attacking
For a long time I assumed nobody would bother with a personal site. There's no shop and nobody has an account here. Then I listed what this site actually has:
- An AI chatbot backed by a Gemini API key. Every message costs a little money.
- A password-protected analytics dashboard.
- A contact form that sends email to me.
- A domain with my name on it.
None of that needs a targeted attacker. Bots scan everything, all day, looking for known holes and open endpoints. A chatbot endpoint with weak limits is a free AI proxy for anyone who finds it, and the bill comes to me.
1. Update the Framework First
The site was on Next.js 15.5.18. The audit listed a stack of Next.js advisories, and the worst were remote code execution bugs in the Image Optimization API (the part behind next/image). Some of them affected every 15.5.x release up to 15.5.27.
I expected to be pushed onto Next.js 16, which would have meant a migration. It turned out there was a backport, 15.5.29, that fixes them on the 15 line. Running npm view next dist-tags showed it under the backport tag, which is easy to miss if you only look at latest.
npm view next dist-tags # look for a "backport" tag
npm install next@^15.5.29
npm audit fix # the non-breaking fixes only
npm audit --omit=dev # what's left in production depsAfter that, two warnings were left, both from a copy of PostCSS that ships inside Next.js. PostCSS only processes my own stylesheets at build time, so I'm fine waiting for the next Next.js release to pick up the fix.
One thing to check if your repo has both package-lock.json and yarn.lock: update both, or delete the one you don't use. Otherwise your host might install from the stale file and you deploy the old version anyway.
To avoid finding out by accident next time, turn on Dependabot alerts in the GitHub repo settings. It opens an alert when a dependency gets a security advisory.
2. Rate Limits That Survive Serverless
My chat endpoint already had a rate limit: 10 requests per IP per minute, kept in a JavaScript Map. I had even left myself a comment admitting it “resets on cold start and is NOT shared across serverless instances.”
That comment was the whole problem. On Vercel, API routes run as serverless functions. Traffic is spread over several instances, each with its own memory, and instances get recycled all the time. A bot sending requests fast enough to trigger scaling would hit fresh counters over and over.
The fix is a counter that lives outside the function. I used Upstash Redis through its REST API, so there's no new npm package. Each window gets its own key that expires by itself:
const windowKey = `rl:${key}:${Math.floor(Date.now() / windowMs)}`;
const res = await fetch(`${process.env.UPSTASH_REDIS_REST_URL}/pipeline`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.UPSTASH_REDIS_REST_TOKEN}` },
body: JSON.stringify([
["INCR", windowKey],
["PEXPIRE", windowKey, String(windowMs)],
]),
});
const [incr] = await res.json();
return Number(incr.result) > limit;If the Upstash variables aren't set, or Redis is down, the helper falls back to the old in-memory Map. A weak limit beats no limit, and local development keeps working without a Redis account.
I moved this into one shared lib/rateLimit.js and used it in three places: the chat, the analytics tracking endpoint, and the admin login. Before, two of those had their own copy-pasted limiter and the login had none.
For the client IP, I read x-real-ip first. Vercel sets that header itself and overwrites x-forwarded-for, so a visitor can't fake a new IP for every request by sending their own header. If you host somewhere else, check what your platform does, because on a plain server those headers are whatever the client sends.
3. Put a Ceiling on the AI Bill
A per-IP limit doesn't help much against a botnet. A thousand IPs at 10 messages a minute each is still a lot of Gemini calls. So the chat now also has a site-wide cap: 500 messages a day across all visitors. Real visitors don't come close to that. When it's reached, the assistant says it's taking a break and points people to the contact form.
const DAILY_LIMIT = Number(process.env.CHAT_DAILY_LIMIT) || 500;
if (await isRateLimited("chat:global", DAILY_LIMIT, DAY_MS)) {
return res.status(429).json({
error: "The assistant is taking a break for today. Please use the contact form instead.",
});
}That check runs only after the request has passed validation, so junk requests don't eat into the daily budget.
The code cap is my first line. The second one lives in Google Cloud, because it still works if my code has a bug:
- Find the key's project. In Google AI Studio, the API keys page shows which Cloud project each key belongs to and whether it's on the free or paid tier. On the free tier, Google can't bill you at all; the worst case is the chat going quiet.
- Set a budget alert. Billing → Budgets & alerts → Create budget, with a small monthly amount. This only sends email. It does not stop anything, which surprised me.
- Lower the quota. APIs & Services → Generative Language API → Quotas. Lower the requests-per-day limit for the model you use. This is the hard stop: past the quota, requests fail instead of costing money.
- Restrict the key. APIs & Services → Credentials → your key → API restrictions → only the Generative Language API. If the key ever leaks, it can't be used for other Google services on your account. Leave application restrictions off, because the key is called from Vercel's servers, not from a browser.
4. Stop Password Guessing on the Admin Page
The analytics dashboard has one shared password. The login code was decent: it compares passwords with crypto.timingSafeEqual so response timing doesn't leak anything, and the session cookie is signed, HttpOnly, Secure, and SameSite=Strict.
But nothing stopped someone from trying a million passwords. Now an IP gets 5 attempts per 15 minutes, then a “try again later” message. With that limit and a long random password, guessing is hopeless. The bots that try common passwords on anything that looks like an admin page get five tries and move on.
The password itself matters as much as the limit. Mine is a generated string from a password manager, 20+ characters.
5. Security Headers, Including a Real CSP
My next.config.js already sent some security headers. While reading it I found the same block listed twice under two different path patterns, and it still included X-XSS-Protection, which modern browsers ignore. It was missing the header that does the most work: a Content-Security-Policy.
A CSP tells the browser which sources it may load scripts, styles, fonts, and connections from. If someone manages to inject <script src="https://evil.example"> into a page, the browser refuses to load it. To write mine I searched the built HTML for every external host. The list was short: Google Fonts and Formspree.
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' data: https://fonts.gstatic.com",
"img-src 'self' data: blob:",
"connect-src 'self' https://formspree.io",
"frame-ancestors 'self'",
"form-action 'self' https://formspree.io",
"object-src 'none'",
"base-uri 'self'",
].join("; ");There's a compromise in there. 'unsafe-inline' for scripts weakens the policy, because it allows inline scripts. The Pages Router adds a few of its own, and the strict alternative is a per-request nonce, which needs middleware and turns static pages into dynamic ones. For a portfolio with no user-generated content, blocking every outside script source gets me most of the benefit. 'unsafe-eval' is only there in development, for hot reload.
Next to it I added a Permissions-Policy that turns off the camera, microphone, geolocation, payment, and USB APIs. The site uses none of them, so there's no reason to let an injected script ask. I also set poweredByHeader: false so responses stop announcing “Next.js” to every scanner.
Test a new CSP in a real browser before you deploy. Open each page type, open the console, and look for “Refused to load” errors. A wrong CSP fails silently for you and loudly for your visitors: missing fonts, a dead contact form.
6. A Honeypot for Contact Form Spam
The contact form goes through Formspree, which already filters some spam. I added a honeypot on top: a field named _gotcha that people never see. Bots fill in every field, and Formspree silently drops any submission where _gotcha has a value.
<div aria-hidden="true" style={{ position: "absolute", left: "-10000px" }}>
<label htmlFor="_gotcha">Leave this field empty</label>
<input id="_gotcha" type="text" name="_gotcha" tabIndex={-1} autoComplete="off" />
</div>The details are about accessibility. aria-hidden keeps screen readers from announcing it, tabIndex={-1} keeps keyboard users from tabbing into it, and autoComplete="off" stops the browser from filling it in for a real person. I moved it off-screen instead of using display: none, since some bots skip fields that are hidden that way.
7. The Accounts Around the Code
Everything above protects the code. Someone who gets into your GitHub or Vercel account doesn't need any of it, because they can deploy whatever they want under your domain. This part has no clever code, and I'd argue it matters most:
- Two-factor authentication on GitHub, Vercel, Supabase, Google, and the domain registrar. Use an authenticator app or a passkey rather than SMS where you can.
- Registrar lock on the domain, so it can't be transferred away without extra steps.
- Secrets only in environment variables, with
.env.localin.gitignore. Rungit log --all -- .env .env.localonce to make sure none were ever committed in the past.
If you use AI coding tools on a project with secrets, I wrote about how I keep Claude Code away from keys and private files.
What I Checked and Left Alone
Some things were already in good shape, and it's worth knowing what to look for:
- The Supabase service-role key is only used on the server, and the analytics table has row-level security turned on with no public policies. The public anon key can't read or write a single row.
- The chatbot validates input: message length is capped, history is limited, and only known roles are passed to the model. It has no tools and no database access, so a prompt injection can make it say something silly, but it can't do anything.
/admin/is disallowed inrobots.txtand the dashboard isnoindex. That doesn't hide it from attackers, but it keeps it out of search results.
I also skipped a CAPTCHA on the chat and a paid web application firewall. With the rate limits and the quota in place, both felt like more friction than this site needs. If the logs ever show sustained abuse, Vercel's firewall rules are the next step.
The Short Version, as a Checklist
- Run
npm audit --omit=devand update the framework, checking for backport releases. - Keep both lockfiles in sync, or delete one.
- Turn on Dependabot alerts.
- Move rate limits to a shared store if you're on serverless.
- Add a site-wide daily cap to anything that calls a paid API.
- Set a budget alert, a quota, and API restrictions on the AI key.
- Limit login attempts and use a long random password.
- Add a Content-Security-Policy and Permissions-Policy, then test in a browser.
- Add a honeypot to the contact form.
- Turn on 2FA everywhere and lock the domain.
Frequently Asked Questions
Does a personal portfolio site really need security work?
Yes, if it does anything beyond static pages. A portfolio with a contact form, an AI chatbot, or an admin page has things worth abusing: an API key that costs money per request, a login someone can guess at, and a form bots can fill with spam. Bots scan every site they can find, so being small doesn't make you invisible.
Why doesn't an in-memory rate limiter work on Vercel?
On Vercel, API routes run as serverless functions. Traffic is spread across several instances, each with its own memory, and an instance can be shut down and restarted at any time. A counter kept in a JavaScript Map only sees the requests that reached that one instance and resets on every cold start. A shared store like Upstash Redis keeps one count for everybody.
Does a Google Cloud budget stop charges on my Gemini API key?
No. A budget in Google Cloud Billing only sends email alerts when spending crosses the thresholds you set. To actually stop requests, lower the quota for the Generative Language API (for example, requests per day) under APIs & Services. Once the quota is used up, Google rejects further requests instead of charging for them.
What is a honeypot field in a contact form?
A honeypot is an extra form field that is hidden from people and screen readers. Real visitors never see it, so they leave it empty. Spam bots tend to fill in every field they find. If the honeypot has a value, the submission is thrown away. Formspree supports this with a field named _gotcha.
