Keep model API keys server-side, scope them, and rotate safely
For developers shipping apps that call model providers, this shows the concrete setup to keep provider API keys off the client, restrict what each key can do, and rotate keys without downtime. You will end with a server-only key path, separate scoped keys per environment/service, and a repeatable rotation procedure with verification commands.
TL;DR — Do not ship model provider API keys to browsers, mobile apps, desktop clients, or public repos. Put the key only on your server, expose a narrow backend endpoint that calls the provider, create separate keys per environment/service with the smallest available permissions, and rotate by running old+new keys in parallel until traffic proves the new key works. Reading time: ~5 min
Goal
When you finish, your app will call the model provider only from your backend, your frontend will never receive the provider key, each environment/service will use its own scoped key, and you will have a no-downtime rotation procedure you can verify with logs and HTTP checks.
Prerequisites
- Access to your model provider account with permission to create and revoke API keys
- Access to your server or deployment platform secrets store
- A backend service you can edit and redeploy
- Node.js >= 20 if using the Node examples — check with:
node --version
curl>= 8 — check with:
curl --version
jqfor JSON inspection — check with:
jq --version
- Your current key inventory: which app, environment, and service uses each existing key
- A place to store key metadata outside code, such as
docs/secrets-inventory.mdor your password manager
Steps
Step 1: Find and remove any client-side exposure
Search your repo for provider keys, Authorization headers, and direct provider URLs from frontend code.
git grep -nE '(sk-|api[_-]?key|Authorization: Bearer|https://api\.)' -- . ':(exclude)package-lock.json' ':(exclude)yarn.lock'
If you have a web app, also inspect the built assets for leaked keys:
npm run build
grep -RInE '(sk-|api[_-]?key|Authorization: Bearer)' dist/ build/ .next/ 2>/dev/null
What you should see when this succeeds: no provider key values in frontend code or built assets; any direct provider calls are identified for replacement.
Step 2: Move provider calls behind a backend endpoint
Replace client-to-provider traffic with client-to-your-backend traffic.
View as text
[Browser] ---- 1. POST /api/ai ----> [Your Backend] [Your Backend] ---- 2. Authorization: Bearer KEY ----> [Model Provider] [Model Provider] ---- 3. JSON response ----> [Your Backend] [Your Backend] ---- 4. filtered result ----> [Browser]
Node/Express example:
npm install express
import express from "express";
const app = express();
app.use(express.json({ limit: "1mb" }));
app.post("/api/ai", async (req, res) => {
const r = await fetch("https://api.example-model-provider.com/v1/responses", {
method: "POST",
headers: {
"content-type": "application/json",
"authorization": `Bearer ${process.env.MODEL_API_KEY}`
},
body: JSON.stringify({
model: "your-model-name",
input: req.body.input
})
});
const body = await r.text();
res.status(r.status).type(r.headers.get("content-type") || "application/json").send(body);
});
app.listen(3000, () => console.log("listening on :3000"));
Frontend change: call your backend, not the provider.
const r = await fetch("/api/ai", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ input: "Summarize this" })
});
What you should see when this succeeds: browser devtools shows requests only to your domain, and the provider domain no longer appears in the Network tab.
Step 3: Put the key in server-side secrets, not in code or .env committed to git
For local development, keep secrets in an untracked file:
printf '.env\n.env.*\n!.env.example\n' >> .gitignore
printf 'MODEL_API_KEY=replace-me\n' > .env.example
printf 'MODEL_API_KEY=YOUR_REAL_KEY\n' > .env
Load it locally with Node:
node --env-file=.env server.js
For a Linux server managed by systemd, store the key in a root-readable env file:
⚠️ This step restarts the service. Do it in a maintenance window if you only have one instance.
sudo install -o root -g root -m 600 /dev/null /etc/myapp/myapp.env
sudo sh -c 'printf "MODEL_API_KEY=YOUR_REAL_KEY\n" > /etc/myapp/myapp.env'
sudo mkdir -p /etc/systemd/system/myapp.service.d
sudo tee /etc/systemd/system/myapp.service.d/override.conf >/dev/null <<'EOF'
[Service]
EnvironmentFile=/etc/myapp/myapp.env
EOF
sudo systemctl daemon-reload
sudo systemctl restart myapp
sudo systemctl show myapp --property=EnvironmentFiles
What you should see when this succeeds: the service starts cleanly, and systemctl show lists /etc/myapp/myapp.env.
Step 4: Create separate scoped keys per environment and service
In your provider's dashboard, create distinct keys with names like:
myapp-prod-apimyapp-staging-apibatch-worker-prod
Use the smallest permissions the provider offers. Typical options, if available, are:
- restrict to specific API families or endpoints
- deny management/admin actions
- restrict to one project/workspace
- set an expiration date
- restrict source IPs for server-to-provider traffic
Record the mapping in a file that does not contain secret values:
| key name | env | service | permissions | created | expires | owner |
|--------------------|---------|--------------|---------------------|------------|------------|-------|
| myapp-prod-api | prod | web-api | inference only | 2026-10-30 | 2027-01-30 | team |
| myapp-staging-api | staging | web-api | inference only | 2026-10-30 | 2027-01-30 | team |
| batch-worker-prod | prod | batch-worker | inference only | 2026-10-30 | 2027-01-30 | team |
What you should see when this succeeds: each deployment uses its own key, and revoking one key would affect only one environment or service.
Step 5: Add key identifiers to logs without logging secrets
Log a non-secret key label from config, not the key itself.
const keyLabel = process.env.MODEL_API_KEY_LABEL || "unknown";
console.log(JSON.stringify({ msg: "provider request", key_label: keyLabel }));
Set it alongside the secret:
sudo sh -c 'printf "MODEL_API_KEY=YOUR_REAL_KEY\nMODEL_API_KEY_LABEL=myapp-prod-api\n" > /etc/myapp/myapp.env'
sudo systemctl restart myapp
What you should see when this succeeds: application logs include "key_label":"myapp-prod-api" and never include the actual bearer token.
Step 6: Rotate keys with overlap, then revoke the old one
Create a new key in the provider dashboard, for example myapp-prod-api-2026-10-30, with the same scope as the old key. Update the server secret to the new value but keep the old key active during rollout.
sudo sh -c 'printf "MODEL_API_KEY=NEW_REAL_KEY\nMODEL_API_KEY_LABEL=myapp-prod-api-2026-10-30\n" > /etc/myapp/myapp.env'
sudo systemctl restart myapp
journalctl -u myapp -n 20 --no-pager
Test the live endpoint:
curl -sS -X POST http://127.0.0.1:3000/api/ai \
-H 'content-type: application/json' \
-d '{"input":"ping"}' | jq .
After you see successful traffic on the new key label in logs and metrics, revoke the old key in the provider dashboard.
What you should see when this succeeds: requests continue returning 200 after the restart, logs show the new key_label, and revoking the old key causes no errors.
Verify it works
Run these checks end to end.
- Frontend does not call the provider directly:
curl -sS https://your-app.example.com | grep -Eo 'https://api\.[^"'"' ]+' || true
Expected: no output.
- Your backend endpoint works:
curl -i -sS -X POST https://your-app.example.com/api/ai \
-H 'content-type: application/json' \
-d '{"input":"ping"}'
Expected output shape:
HTTP/2 200
content-type: application/json
{"id":"...","output":[...]}
- A bad or revoked key fails the way you expect. Temporarily test in a non-prod shell with an invalid key:
MODEL_API_KEY=invalid node --env-file=.env server.js
curl -i -sS -X POST http://127.0.0.1:3000/api/ai \
-H 'content-type: application/json' \
-d '{"input":"ping"}'
Expected output shape:
HTTP/1.1 401 Unauthorized
content-type: application/json
{"error":{"message":"Invalid API key"}}
- Logs identify the active key label only:
journalctl -u myapp -n 50 --no-pager | grep 'key_label'
Expected: lines containing the label, with no bearer token value anywhere.
Common pitfalls
Frontend bundle still contains the key
Mistake: putting MODEL_API_KEY in a frontend build variable such as NEXT_PUBLIC_*, VITE_*, or a mobile app config.
Symptom: grep -RIn 'sk-' build/ finds the key, or browser devtools shows requests directly to the provider.
Fix: remove the key from all client-exposed env vars and route calls through /api/ai on your backend.
One key shared across prod, staging, and batch jobs
Mistake: reusing a single provider key everywhere.
Symptom: you cannot revoke or rotate one consumer without breaking others; logs do not tell you which service is responsible for spend or failures.
Fix: create one key per environment and service, then set MODEL_API_KEY_LABEL to match the inventory name.
Logging the Authorization header
Mistake: enabling debug logging that dumps outbound request headers.
Symptom: logs contain Authorization: Bearer ...; your SIEM or log shipper now holds live secrets.
Fix: redact authorization in HTTP client logging or disable header logging entirely before deploying.
Rotation done by revoking first
Mistake: deleting the old key before the new key is deployed everywhere.
Symptom: sudden 401 Unauthorized or provider error responses immediately after revocation.
Fix: create new key, deploy it, verify live traffic on the new label, then revoke the old key.
Secret updated on disk but process still uses the old value
Mistake: editing /etc/myapp/myapp.env without restarting or reloading the service.
Symptom: logs still show the old key_label, or requests keep failing with the old key after you changed the file.
Fix: run sudo systemctl restart myapp and verify with journalctl -u myapp -n 20 --no-pager.
Committed .env file remains in git history
Mistake: adding .env to .gitignore after it was already committed.
Symptom: the key is gone from the working tree but still visible in git log or on the remote.
Fix: rotate the leaked key immediately, then remove the file from the index with git rm --cached .env and rewrite history if your org requires it.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI