I've been building apps for a while now. I've deployed projects, shipped features, and even won a hackathon. But last week, while casually poking around the Chrome DevTools Network tab on my own project, I saw something that made my stomach drop.
The project is ChwiiX — a movie streaming discovery app I built with React and Vite. It pulls movie data from TMDB (The Movie Database), a popular third-party API that gives you access to movie listings, posters, ratings, trailers, and more. To use it, TMDB gives you an API key — a unique token that identifies your app and tracks your usage. Think of it like a password for your app.
And there it was, sitting in plain text in every single outgoing request in the Network tab:
https://api.themoviedb.org/3/movie/popular?api_key=sk_XXXXXXXXXXXXXXXX
My TMDB API key. Fully exposed. In the browser. For anyone to see.
The worst part? I thought I had secured it. It was in my .env file. It was in Vercel's environment variable panel. I had done "the right things" — or so I thought.
This post is about what actually went wrong, why it happens, and the correct fix that makes your key truly invisible.
First, How Was ChwiiX Structured?
To understand the problem, it helps to understand how the app was originally set up — because this is a pattern a lot of frontend developers follow without realizing the security implications.
ChwiiX is a Vite + React SPA (Single Page Application). That means the entire app is JavaScript that runs in your browser. There is no backend server of my own. When the app needs movie data, it calls TMDB directly from the browser, like this:
User's Browser → TMDB API
To make those requests, I needed to attach my API key to every call. So I stored it in a .env file at the root of the project:
VITE_BASE_API_KEY=my_secret_tmdb_key
VITE_BASE_URL=https://api.themoviedb.org/3
And in my code, I had a central config file — src/services/api.ts — that exposed those values to the rest of the app:
// src/services/api.ts
export const BASE_URL = import.meta.env.VITE_BASE_URL;
export const API_KEY = import.meta.env.VITE_BASE_API_KEY;
Then every page or hook that needed movie data would import those values and build a URL like this:
// Example: fetching popular movies anywhere in the app
const response = await fetch(`${BASE_URL}/movie/popular?api_key=${API_KEY}`);
This felt clean and organized. The key wasn't hardcoded anywhere in the source. It was an environment variable. I even configured it in Vercel's dashboard so it wouldn't live in my Git history. I thought I was doing everything right.
The Misconception: .env ≠ Secret
Here's the trap. When you add a variable to .env or to Vercel's dashboard, it feels secure because it's not hardcoded in your source. But there's one critical question you need to ask:
Where does this code actually run?
In a Vite React SPA, the answer is: the browser. Every file inside your src/ folder gets compiled and bundled into JavaScript files that get shipped to whoever visits your site. There is no server of yours running in the middle — it's just files sent directly to the browser.
Vite has a safety gate for this — it only exposes environment variables that start with VITE_ to the browser bundle. This is intentional. The VITE_ prefix is your explicit declaration: "I want this value available on the client side." Variables without the prefix stay undefined in the browser.
The problem? I used the VITE_ prefix on my API key. I was unknowingly opting into sending it to every visitor's browser. Vite did exactly what I told it to do.
Putting the key in Vercel's env panel just means it doesn't live in my Git history. It still ends up baked into the compiled JavaScript bundle that Vercel serves to the public.
Why the Network Tab Makes It Obvious
Even if the key was buried deep in the bundle, the Network tab removes all ambiguity. Because the browser is calling TMDB directly, every single request URL shows up in DevTools — including the api_key query parameter attached to every URL.
Anyone visiting ChwiiX can:
- Press
F12→ open the Network tab - Browse any page that loads movies
- Click any request going to
api.themoviedb.org - Read the full URL — including
?api_key=YOUR_KEY - Copy it and use it however they want That could mean burning through your free tier quota, racking up charges on a paid plan, or — if it's a more sensitive API like a payment or AI service — something far worse.
How Companies Actually Protect Their Keys
The reason you can't find Spotify's or Netflix's API keys in their Network tab is simple: they never let the browser talk to third-party APIs directly. They always put their own server in the middle:
Browser → Your Server (secretly adds the key) → Third-Party API
The browser calls your own endpoint — something like /api/movies/popular. Your server receives that request, quietly attaches the real API key, forwards the request to the third-party API, gets the response, and sends it back to the browser.
The key never travels to the browser at any point.
This pattern is called a proxy. And for a Vercel-deployed app like ChwiiX, the cleanest way to implement one is with a serverless function.
The Fix: A Serverless Proxy
A serverless function is a small piece of server-side code that Vercel runs on its own infrastructure — not in the browser. It has access to real server environment variables that are never bundled into client JavaScript.
Here's how I restructured ChwiiX to use one.
Step 1: Understand the new request flow
Before the fix, every request from ChwiiX went like this:
Browser → https://api.themoviedb.org/3/movie/popular?api_key=SECRET_KEY
After the fix, it goes like this:
Browser → /api/tmdb/movie/popular (no key — hits your serverless function)
↓
Vercel Serverless Function (adds the key server-side)
↓
https://api.themoviedb.org/3/movie/popular?api_key=SECRET_KEY
The browser only ever sees /api/tmdb/... — your own domain, with no key attached anywhere.
Step 2: Create the proxy serverless function
At the root of your project (not inside src/ — this runs on the server, not the browser), create a file at api/tmdb/[...path].ts:
// api/tmdb/[...path].ts
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default async function handler(req: VercelRequest, res: VercelResponse) {
// process.env here is server-side — it never reaches the client
const apiKey = process.env.TMDB_API_KEY;
// Reconstruct which TMDB endpoint was requested
// e.g. if browser called /api/tmdb/movie/popular → path = "movie/popular"
const path = (req.query.path as string[]).join('/');
// Build the real TMDB URL, injecting the key server-side
const params = new URLSearchParams(req.query as Record<string, string>);
params.delete('path'); // remove the internal routing param
params.delete('api_key'); // strip anything the client might have sent
params.set('api_key', apiKey!); // inject the real secret key here, on the server
const tmdbUrl = `https://api.themoviedb.org/3/${path}?${params.toString()}`;
// Forward the request to TMDB and return the result to the browser
const response = await fetch(tmdbUrl);
const data = await response.json();
res.status(response.status).json(data);
}
The filename [...path].ts is a Vercel catch-all route — this single function handles every TMDB endpoint automatically. Whether the browser requests /api/tmdb/movie/popular or /api/tmdb/search/movie?query=batman, it all routes here.
Step 3: Update the API config to point at the proxy
Back in src/services/api.ts — the central config file — swap the values:
// Before — browser calls TMDB directly, key is exposed in every request
export const BASE_URL = import.meta.env.VITE_BASE_URL; // "https://api.themoviedb.org/3"
export const API_KEY = import.meta.env.VITE_BASE_API_KEY; // real key, visible in browser
// After — browser calls your proxy, key stays on the server
export const BASE_URL = '/api/tmdb'; // your own serverless function
export const API_KEY = ''; // empty — the proxy handles the key server-side
The best part: every other file in the app stays exactly the same. All the hooks and pages were already building URLs like ${BASE_URL}/movie/popular?api_key=${API_KEY}. They now just hit the proxy instead of TMDB directly, without any other changes needed.
Step 4: Tell Vercel not to intercept /api requests
Vercel SPAs have a default catch-all rewrite rule that sends every URL to index.html so React Router can handle navigation. But this would accidentally swallow your /api/tmdb/... requests before they ever reach your serverless function.
Fix it in vercel.json:
{
"rewrites": [
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}
The pattern (?!api/) means: "send everything to index.html — except paths starting with api/." Those get routed to the serverless function instead.
Step 5: Fix local development
Here's a catch that trips people up: Vite's dev server (npm run dev) doesn't run Vercel serverless functions. So if you run ChwiiX locally after this change, requests to /api/tmdb would hit nothing and movies wouldn't load.
The solution is Vite's built-in proxy feature. It runs in Node.js (server-side, just like the serverless function) and handles the key injection the same way. Add this to your vite.config.ts:
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api/tmdb': {
target: 'https://api.themoviedb.org/3',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/tmdb/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
// This runs in Node.js — the key injection never reaches the browser
const url = new URL(req.url!, 'http://localhost');
url.searchParams.set('api_key', process.env.TMDB_API_KEY!);
proxyReq.path = `${url.pathname}${url.search}`;
});
},
},
},
},
});
Then create a .env.local file in the project root (Vite already adds this to .gitignore by default):
TMDB_API_KEY=your_actual_tmdb_key_here
Notice: no VITE_ prefix. Without that prefix, Vite will never bundle this value into the browser — it stays in Node.js only.
Here's how the security model looks across both environments:
| Environment | Who handles /api/tmdb? |
Where is the key? |
|---|---|---|
npm run dev |
Vite proxy (Node.js) |
.env.local — never reaches the browser |
| Vercel production | Serverless function | Vercel env vars — never reaches the browser |
Open the Network tab after these changes. Every movie request goes to /api/tmdb/movie/popular — your own domain, no key visible anywhere.
Clean Up Your Vercel Environment Variables
Go to your Vercel project → Settings → Environment Variables:
-
Add
TMDB_API_KEY= your actual key (noVITE_prefix — keep it server-side) -
Remove
VITE_BASE_API_KEYandVITE_BASE_BASE_URL— they are no longer needed and shouldn't exist Redeploy. Done.
The Mental Model to Take Away
Every time you write import.meta.env.VITE_*, ask yourself: "Am I okay with every visitor to my site seeing this exact value?" If the answer is no — if it's an API key, a private endpoint, a payment credential — it does not belong in client-side code. Full stop.
The rule is simple:
Secrets belong on servers. Browsers are public.
A serverless proxy adds about 30 minutes of work. It's the correct architecture, and it scales well — when you eventually want to add caching, rate limiting, or authentication to your API layer, your proxy function is exactly where that logic belongs anyway.
One last thing: if you've already deployed an app with an exposed key, rotate it immediately. Go to your API provider's dashboard, invalidate the old key, and generate a new one. Assume it was seen from the moment you first deployed.
ChwiiX taught me more about security architecture in one afternoon than months of reading docs. Sometimes the best lessons come from your own mistakes.
Top comments (0)