An API key or secret token is, functionally, a password. If your WEATHER_API_KEY or STRIPE_SECRET_KEY ends up somewhere a visitor can read it, they can use it exactly as if it were theirs — running up your bill, exhausting your rate limit, or sending requests that look like they came from you.
The uncomfortable part: they don't need to hack anything. Every request your frontend makes, and every response it receives, is fully visible in the browser's Network tab. Nothing that reaches the client is private, no matter how deeply it's nested in a response object.
⚠️ You also don't get the benefit of "nobody will notice." Automated bots scan public GitHub repositories, and even live websites, constantly — specifically looking for text patterns that resemble API keys. A secret exposed in a public repo or a public response is often found and abused within minutes, not eventually.
Vercel gives every deployment HTTPS automatically, and it's worth understanding exactly what that buys you: encryption in transit — nobody sitting on the same coffee-shop Wi-Fi, or your internet provider, can read the traffic between a visitor's browser and your server. That's genuinely important, and free, and worth appreciating.
What HTTPS does not do is protect data from the person the request was addressed to. A secret sent to the browser arrives over a perfectly encrypted connection — and then sits there, in full view, for the browser's own owner to read in DevTools whenever they like. Encryption in transit and "safe to send" are two completely different properties, and it's easy to mistake one for the other.
💡 Key takeaway: HTTPS keeps a secret safe on the way to the browser. It does nothing at all once it arrives. The only real protection is never sending it there in the first place.
It's worth doing this once, deliberately, so the idea stops being abstract. Open any deployed website, open DevTools, and go to the Sources tab (or Network, filtered to .js files). Every piece of JavaScript your browser is running — including any string that was ever accidentally embedded in it — is sitting right there, fully readable, often only lightly minified.
Search that source for a word you know shouldn't be there — secret, key, password — and on a poorly-configured site, you will occasionally find exactly that. Minification makes code hard to read, not hard to search; a secret key doesn't stop being a working secret key just because the variable name around it got shortened to a.
⚠️ "It's minified" is not a security measure. Minification is a size optimisation for network transfer, nothing more. Anything in your JavaScript bundle should be treated as public, permanently, from the moment it ships — because functionally, it already is.
Your portfolio is a static Vite build with no traditional backend — so where would a secret even live? Vercel (and most static hosts) support serverless functions: any file in a top-level /api folder is deployed as its own small piece of real, server-side Node code, separate from your bundled frontend. Your contact form, for example, might post to /api/contact, which is the one place allowed to hold an email service's secret key — because it's the one place the browser never sees the source of.
💡 A note on function syntax. The
(request, response)style used below is Vercel's classic Node signature, and it's still fully supported. Newer Vercel functions can also use the web-standard form —export function POST(request: Request)returning aResponse, the sameRequest/Responseobjects the browser itself uses. Both are valid; you'll run into each in real codebases, so it's worth recognising the two shapes.
Here's a mistake that's easy to make and easy to miss, because the code runs perfectly fine — right up until something goes wrong:
// api/contact.js — a Vercel Serverless Function
export default async function handler(request, response) {
const { name, email, message } = request.body;
const apiKey = process.env.RESEND_API_KEY;
try {
const result = await sendEmail({ name, email, message, apiKey });
response.status(200).json({ success: true });
} catch (error) {
// ❌ apiKey ends up in the response body the moment sendEmail fails
response.status(500).json({ error: error.message, apiKey });
}
}
This was almost certainly added to make debugging easier during development — and then nobody removed it before shipping. The fix is just as simple as the mistake:
export default async function handler(request, response) {
const { name, email, message } = request.body;
const apiKey = process.env.RESEND_API_KEY;
try {
const result = await sendEmail({ name, email, message, apiKey });
response.status(200).json({ success: true });
} catch (error) {
response.status(500).json({ error: "Unable to send message" });
}
}
⚠️ The rule is absolute, not situational: nothing read from
process.envshould ever appear inside a response body, a thrown error message sent to the client, or a log line that a client-facing tool might surface. If you need to debug it, log it server-side only, where the client can never see it.
The fix above moved the secret out of the response — but it's worth being careful about where "just log it server-side instead" actually leads, too. Server-side logs still get read by something: a dashboard, a log aggregator, sometimes a whole team with broader access than you'd expect.
A safer habit is to log that a key was used, not the key itself:
console.error("sendEmail failed", { hasApiKey: Boolean(apiKey), message: error.message });
This tells you everything you need in order to debug — the key was present, and here's exactly what broke — without ever writing the actual secret anywhere it might be retained, forwarded, or viewed by more people than you intended.
💡 A useful habit generally, not just a rule for this one case: before logging any object, ask what's inside it.
console.log(requestBody)is convenient during development and a liability the moment that request body might ever contain something private.
A serverless function is a boundary — the one place your code meets input from outside your control. That's exactly where validation belongs: check that a request body has the shape you expect before you use it, rather than trusting it and hoping.
This isn't about defensive-programming everywhere. Internal function calls between code you wrote can trust each other. But the moment data crosses from "anyone on the internet" into your system, assume nothing about its shape until you've checked.
You may remember, from last week, testing a contact form's client-side validation — checking that an empty submission shows an error before it ever reaches the network. That check is genuinely useful: it gives a real user instant feedback without waiting on a round trip. But it protects nothing.
Anyone can bypass your React component entirely and call your serverless function directly:
curl -X POST <https://your-portfolio.vercel.app/api/contact> \
-H "Content-Type: application/json" \
-d '{"email": "not-an-email", "message": "x"}'
No browser, no form, no client-side validation anywhere in sight — just a raw request straight at your endpoint. If isValidEmail and isValidMessage from last week's pair exercise only ever run inside the React component, a request like this one sails straight through untouched.
⚠️ The rule: validate on the client for a better user experience. Validate on the server because you have to. If a check only exists in the browser, it isn't really a check — it's a suggestion the rest of the internet is entirely free to ignore.
If you've ever seen a browser console error mentioning CORS while calling an API from client code, this is what that was about. Cross-Origin Resource Sharing is a browser-enforced rule: by default, JavaScript running on your-portfolio.vercel.app is not allowed to read a response from a different origin, unless that other origin explicitly says it's fine.
Your own /api functions don't trigger this at all — they share the same origin as your frontend. It comes up the moment you call someone else's API directly from client-side code, or if you ever open your own API up to be called from other sites.
💡 Worth knowing: CORS is not something you "fix" by disabling it. The error is the browser protecting users, not you. If you control the API and genuinely need another site to call it, you configure that server to explicitly allow it. If you don't control it, that's usually a sign the call belongs behind your own serverless function instead of the browser — which conveniently also happens to be exactly the pattern that keeps secrets off the client.
Not every vulnerability is a mistake you personally wrote. Every package in your package-lock.json is code you're implicitly trusting, and occasionally one of them has a known security issue discovered well after you first installed it.
npm audit
This checks your installed dependencies against a public vulnerability database and reports anything concerning, often with an automatic fix available via npm audit fix. On GitHub, Dependabot does the equivalent of this continuously and automatically — opening a pull request the moment a fix is available for something you depend on, so it flows through your normal CI pipeline and branch protection exactly like any other change.
💡 This is CI/CD and security meeting in practice. A Dependabot PR is just a pull request. It runs your pipeline, it needs to pass, and trunk-based development means it can merge the same day it opens instead of sitting untouched for months while the vulnerability stays live in production.
One more habit worth carrying into any real API key you generate: give it the smallest set of permissions that actually gets the job done, not the broadest one available by default.
Most services that issue API keys let you scope them — a send-only email key instead of one that can also read your entire account's history, a database credential that can only insert into one table instead of one with full administrative rights. If a key like that ever does leak despite everything else in this material, the damage it can do is bounded by exactly what you gave it permission to do in the first place.
💡 Key takeaway: every other safeguard in this material reduces the chance of a leak. Least privilege is different — it reduces the consequence if one happens anyway. Both matter, for the same reason a seatbelt and a speed limit both matter, even though only one of them prevents the crash.
Writing your own /api/contact function isn't the only option for a portfolio contact form, and it's worth knowing the alternative exists. Services like Formspree or EmailJS are built specifically for static sites that have no backend of their own: you submit the form directly to their endpoint, using a public identifier rather than a secret key, and they handle sending the email on your behalf.
This sidesteps the entire "where do I safely keep a secret" question for that one form, at the cost of depending on a third party. Building your own function gives you more control and is genuinely worth doing once, for the practice — but it isn't the only correct answer, and reaching for one of these services for a simple contact form is a completely reasonable engineering decision, not a shortcut you should feel bad about.
Before you consider a deployment finished, it's worth a five-minute pass over exactly this:
import.meta.env — is every result intentionally prefixed with VITE_? Then search any /api functions for process.env — is every one of those values kept out of the response body?.gitignore — does it list .env, .env.local, and any other file holding real secrets?npm audit — is there anything reported that you haven't looked at yet?💡 None of this requires new tools. It's the same DevTools and search bar you already use every day — just pointed at a different question.
The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.