An API key is a bearer credential: whoever holds it can use it, and there is no second factor on an API call. On APIVerve, holding your key means spending your credits. That makes key handling most of your security posture, and the failures are boringly predictable. Keys end up in three places they should not: in source control, in browser code, and in logs.
This lesson closes each of those, in that order, and then covers the part people skip until it is too late: what to do when one gets out anyway.
01Keys live in the environment
You have been doing this since lesson one: the code reads APIVERVE_API_KEY from the environment and fails at startup if it is missing. The point of an environment variable is that the value is supplied by wherever the code runs (your shell, a CI system, a hosting platform's settings page), not by the code itself. The same file runs in development and production with different keys and contains neither.
const KEY = process.env.APIVERVE_API_KEY;
if (!KEY) throw new Error('APIVERVE_API_KEY is not set');import os
KEY = os.environ["APIVERVE_API_KEY"] # KeyError at startup if unsetTyping export in every new terminal gets old, so most projects keep development values in a .env file and load it at startup.
APIVERVE_API_KEY=YOUR_API_KEY// npm install dotenv
import 'dotenv/config'; // loads .env into process.env
console.log(Boolean(process.env.APIVERVE_API_KEY));
// Node 20.6+ can skip the package: node --env-file=.env app.mjs# python -m pip install python-dotenv
from dotenv import load_dotenv
import os
load_dotenv() # loads .env into os.environ
print(bool(os.environ.get("APIVERVE_API_KEY"))).gitignore before the first commit
A .env file is only safe if it never enters version control. Add it to .gitignore before your first commit, not after you notice it in git status:
.env
.env.*
!.env.example
.dev.varsCommit a .env.example with the variable names and placeholder values instead, so the next person knows what to set.
Order matters because git history is permanent. If .env was committed once and deleted in the next commit, the key is still in the repository, in every clone, and in every fork. Rewriting history is possible but does not reach copies that already exist. Treat a committed key as a leaked key, even in a private repository: private repositories get shared, made public, cloned onto laptops, and read by every integration you have connected.
You committed .env with your key, noticed, and deleted it in the next commit. The repository is private. What is the situation?
02Why the browser cannot hold the key
Lesson one made the argument briefly. Here it is in full, because it is the question every frontend developer asks.
Anything the browser runs, the user can read. A key in a JavaScript bundle can be found by searching the bundle; a key added at runtime can be read in the Network tab on every request; minifying or obfuscating does not change either. "Environment variables" in frontend build tools are not secrets: the bundler replaces import.meta.env.WHATEVER with the literal string at build time. There is no configuration that makes a key in browser code safe, and the security docs say so plainly.
The APIVerve API does accept calls from any origin, so a browser can call it directly. That is useful for a prototype or an internal tool with a throwaway, tightly scoped sub-key. For anything user-facing, the shape is:
browser ---> your server (holds the key) ---> api.apiverve.comYour server is a proxy. The browser calls /api/numbertowords?number=42 on your own domain; the server checks the request, adds the key, forwards it, and returns the answer. Beyond hiding the key, the proxy gives you a place to cache (lesson five), to rate-limit per user, and to rotate the key without shipping a new frontend.
03A minimal proxy in Express
The most important design decision in a proxy is what it refuses to forward. A proxy that forwards any path and any parameter to the API is just your key with extra steps: anyone who finds your endpoint can spend your credits on any API in the catalog. So this one has an allow-list: which APIs, and which parameters of each.
// npm install express@4
import express from 'express';
const KEY = process.env.APIVERVE_API_KEY;
if (!KEY) throw new Error('APIVERVE_API_KEY is not set');
// Which APIs the browser may reach, and which parameters it may set on each.
const ALLOWED = new Map([
['numbertowords', ['number']],
['dictionary', ['word']],
]);
const app = express();
app.get('/api/:id', async (req, res) => {
const names = ALLOWED.get(req.params.id);
if (!names) {
return res.status(404).json({ status: 'error', error: 'Unknown API', data: null });
}
const target = new URL(`https://api.apiverve.com/v1/${req.params.id}`);
for (const name of names) {
const value = req.query[name];
if (typeof value === 'string' && value.length > 0 && value.length <= 100) {
target.searchParams.set(name, value);
}
}
try {
const upstream = await fetch(target, {
headers: { 'x-api-key': KEY },
signal: AbortSignal.timeout(10_000),
});
const body = await upstream.json().catch(() => null);
res.status(upstream.status).json(
body ?? { status: 'error', error: `Upstream HTTP ${upstream.status}`, data: null }
);
} catch {
res.status(502).json({ status: 'error', error: 'Upstream unavailable', data: null });
}
});
app.listen(3000, () => console.log('Proxy on http://localhost:3000'));Run it with node server.mjs and open http://localhost:3000/api/numbertowords?number=42. The browser code from lesson one now works unchanged against it.
The choices that make it safe are small and easy to get wrong:
- A
Map, not an object literal. Withconst ALLOWED = { numbertowords: [...] }, a request for/api/constructorfindsALLOWED['constructor'], which is the built-inObjectfunction and truthy.Map.getonly returns what you put in. - Parameters are copied by name, never spread.
target.search = new URLSearchParams(req.query)would forward anything the caller added. Copying only the allowed names means extra parameters simply vanish. - Values must be single strings of bounded length. Express turns
?word=a&word=binto an array; thetypeofcheck drops it. The length cap stops someone using your credits on enormous inputs. You can go further and validate shape (digits only fornumber), which also saves the credit on input the API would reject anyway. - Only the body goes back. The proxy does not copy upstream headers through. Your credit balance and plan details are your business, not your visitors'.
- The key is never logged or echoed. If you add request logging, log the path and status, not the outgoing headers.
Here is one of the calls the proxy allows, so you can see what the browser will receive:
curl "https://api.apiverve.com/v1/numbertowords?number=42" \
-H "x-api-key: YOUR_API_KEY"This is the body your proxy returns for /api/numbertowords?number=42.
04The same proxy as a Cloudflare Worker
If you do not want to run a server, a Cloudflare Worker does the same job at the edge. Workers use module syntax, and secrets arrive on the env argument instead of process.env.
This version adds one more idea: the proxy can supply parameters the browser does not control. For IP geolocation, the right IP is the visitor's own, which Cloudflare provides in the CF-Connecting-IP header. The browser never sends an IP, so it cannot make you look up someone else's.
const ALLOWED = new Map([
['numbertowords', ['number']],
['dictionary', ['word']],
]);
const json = (obj, status = 200) =>
new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json' } });
export default {
async fetch(request, env) {
if (!env.APIVERVE_API_KEY) {
return json({ status: 'error', error: 'Proxy is not configured', data: null }, 500);
}
const url = new URL(request.url);
const match = /^\/api\/([a-z0-9]+)$/.exec(url.pathname);
if (request.method !== 'GET' || !match) {
return json({ status: 'error', error: 'Not found', data: null }, 404);
}
const id = match[1];
const target = new URL(`https://api.apiverve.com/v1/${id}`);
if (id === 'iplookup') {
const ip = request.headers.get('CF-Connecting-IP');
if (!ip) return json({ status: 'error', error: 'No client IP', data: null }, 400);
target.searchParams.set('ip', ip);
} else if (ALLOWED.has(id)) {
for (const name of ALLOWED.get(id)) {
const value = url.searchParams.get(name);
if (value && value.length <= 100) target.searchParams.set(name, value);
}
} else {
return json({ status: 'error', error: 'Unknown API', data: null }, 404);
}
const upstream = await fetch(target, { headers: { 'x-api-key': env.APIVERVE_API_KEY } });
return new Response(await upstream.text(), {
status: upstream.status,
headers: { 'content-type': 'application/json' },
});
},
};Store the key as a secret, not in wrangler.toml (which is committed), and keep the local copy in .dev.vars, which is why it went into .gitignore above:
npx wrangler secret put APIVERVE_API_KEY # prompts for the value
echo 'APIVERVE_API_KEY=YOUR_API_KEY' > .dev.vars # local dev only
npx wrangler devThe lookup the Worker makes for a visitor is equivalent to this one, with their address in place of the example:
curl "https://api.apiverve.com/v1/iplookup?ip=8.8.8.8" \
-H "x-api-key: YOUR_API_KEY"In the Worker, ip comes from CF-Connecting-IP, never from the browser.
A proxy builds its upstream URL with new URLSearchParams(req.query) so it forwards whatever the browser sends. What is the main problem?
The proxy is now the thing to protect
Moving the key server-side does not make the endpoint private. Anyone can call https://yoursite.com/api/dictionary?word=x from a script. The allow-list caps what they can do; it does not stop them doing it a hundred thousand times. Checking the Origin header helps against other websites embedding your endpoint, but scripts can send any header they like, so it is not access control. Depending on what is behind the proxy, add one or more of: a per-IP or per-user rate limit, a requirement that the user is signed in to your app, and a cache so repeated questions cost nothing. APIVerve also lets you put a lower rate cap and endpoint scoping on the key the proxy uses, so a leak of that key is worth very little.
05When a key leaks anyway
It will happen to someone on your team eventually: a screenshot in a bug report, a key pasted into a support chat, a .env in a public gist. What you do in the first ten minutes matters more than what you do afterwards.
- Rotate immediately. Rotation on APIVerve issues a new key and invalidates the old one instantly, with no grace period. For a planned rotation you roll the new key out first and rotate after. For a leak, invert that: rotate first and accept a few minutes of downtime. A leaked key is spending your credits for as long as it works.
- Update every place the key lives. Hosting settings, CI secrets,
wrangler secret put, teammates'.envfiles. This is where one key per environment pays off: you only touch the places that used the leaked one. - Look at usage. The per-API breakdown in the dashboard shows whether anything you do not recognise was called. An endpoint you never use appearing in analytics is the clearest sign someone else had the key.
- Remove the exposure. Delete the gist, edit the ticket, scrub the commit if the repository is public. This is cleanup, not containment: the rotation in step one is what actually stopped the damage.
The key rotation guide covers the zero-downtime pattern with overlapping sub-keys. Practise it once on a sub-key before you need it under pressure.
06What you have now
Keys in environment variables that fail loudly when missing, a .gitignore that was right from the first commit, a proxy in Express or a Worker that forwards only what you allow, and a rotation plan you can run in minutes. The proxy also left you with a natural place to stop paying for the same answer twice, which is where the course goes next: Caching and credit budgets.