Topic 1.5
Your Server Route and Secrets
In one line
The browser never calls the model provider directly. It calls your own server route, which holds the secret key, checks the request, decides the model and system prompt itself, limits size and rate, and returns only a safe answer.
Think of it like this
A bank cashier. You never walk into the vault. You hand the cashier a slip, and the cashier checks your slip is filled in properly, checks your limit, goes to the vault with their own key, and brings back only what you asked for. Your server route is the cashier. The vault key stays behind the counter, and the slip is never trusted just because it looks right.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Route handler
- A function on your server that handles one URL, for example
POST /api/chat. It receives aRequestand returns aResponse. - Proxy
- A server in the middle that forwards a request to another server after checking it and adding what is needed.
- Environment variable
- A named value set outside the code (like
PROVIDER_API_KEY), read on the server withprocess.env. - NEXT_PUBLIC_
- A Next.js prefix that makes a variable visible in the browser. Never use it for secrets.
- Secret key (API key)
- A password-like string that identifies your account at the provider and is billed to you.
- Validation
- Checking that incoming data has the type, shape and size you expect before using it.
- CORS
- A browser rule that stops a website from reading another website's responses unless that site allows it.
- Rate limit (yours)
- A cap you enforce on how often one visitor may call your route.
Step by step
01Where the key lives
The key sits in an environment variable on your server. The browser never receives it, and the route never returns it. Here is the real shape of the file, and the entry that keeps the key out of git. (Not live: the preview has no environment and no provider.)
PROVIDER_API_KEY=paste-your-key-here
# .gitignore must contain: .env*.local02The route, with a real provider call
This is the production shape of Mitra's route for Phases 1 to 4. It validates, adds the system message and model itself, attaches the key, and sets a timeout. The provider URL and model name are placeholders: use your provider's current documentation.
const MODEL = "provider/model-name"; // chosen by you, never by the request
const SYSTEM = "You are Mitra, the help assistant of Chai & Co. Only answer questions about the shop.";
export async function POST(req: Request) {
const turns = await readConversation(req); // validates: see the live example
if (turns instanceof Response) return turns; // a 400 or 413
let upstream: Response;
try {
upstream = await fetch("https://api.provider.example/v1/chat/completions", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer " + process.env.PROVIDER_API_KEY, // server-only
},
body: JSON.stringify({ model: MODEL, messages: [{ role: "system", content: SYSTEM }, ...turns] }),
signal: AbortSignal.timeout(30_000),
});
} catch {
return Response.json({ error: { code: "provider_timeout" } }, { status: 504 }); // too slow, or no connection
}
if (!upstream.ok) {
console.error("provider error", upstream.status); // details to the log, never to the browser
if (upstream.status === 429) {
const wait = upstream.headers.get("retry-after") ?? "30"; // pass the wait time on (the live example below runs this)
return Response.json({ error: { code: "rate_limited" } }, { status: 429, headers: { "retry-after": wait } });
}
return Response.json({ error: { code: "provider_error" } }, { status: 502 });
}
const data = await upstream.json();
return Response.json({ answer: data.choices[0].message.content });
}03Test the route like an attacker would, defensively
The first live example sends ten requests, most of them wrong on purpose: a visitor trying to pick the model, slipping in a system message, an empty list, a 600-character message, 21 turns, a 9,000-character body, null, plain text. Every bad one is rejected before the provider call, which you can see because the server log line appears only for the two good ones.
Do this for your own routes. The checks you write are only worth what you test them against.
04Keys in the browser: find them before others do
The second example is a small scanner that looks through a built JavaScript file for things that should never be there: a key-like token, a header with Bearer, a call straight to the provider. Run something like this in your build or CI (continuous integration, the automatic checks run on every change). The keys in the example are made up.
05Limit how often
The third example gives each visitor three questions per window. The fourth is refused with 429 and retry-after: 60; another visitor is unaffected; and after the window passes the first visitor may ask again. A pretend clock keeps the example repeatable.
Notice the response is exactly what Topic 1.3's client knows how to read: a 429 with a Retry-After. The client and server agree on one error language.
06When the provider fails behind your route
Your route is a client of the provider and a server for the browser, so it meets both sets of failures from Topics 1.3 and 1.4. The fourth example runs five calls: an answer, a 429 with retry-after: 7, a 401, a 503 and a provider that never answers. The browser sees only your own short errors: the 429 keeps its wait time, the 401 and 503 both become one safe 502, and the silence becomes a 504 after the route's own time limit. The provider's words go to the server log.
Notice that a 401 here means *your* key is wrong. Log it loudly and alert yourself, but tell the visitor only that Mitra is unavailable.
Try it yourself
- 1
Tighten a limit
In the first example change
MAX_TEXTto20and run again."When will it ship?" is 18 characters and passes. Real questions are longer, and the next request, "tries to pick the model", still passes. Run it with a longer question and watch the 400. Limits are a product decision: too tight annoys people, too loose costs money.
- 2
Add a key to the bundle
In the second example add a line
const KEY = "sk-ANOTHERFAKE0123456789abcdef";tobundleDoneRight.The scanner reports the key-like token for the "right" bundle too. Try changing the line to
const KEY = "sk_live_" + "abcdefghijkl";and notice the pattern no longer matches: the scanner only knows the shapes it was given, which is why the rule is "never put keys in browser code", not "scan for them". - 3
Change the window
In the third example set
LIMITto1andWINDOW_MSto10_000, then re-read the output.Visitor A is limited on the second call with
retry-after: 10, and the call at 45 s is allowed again because the 10-second window has long passed. A short window with a small limit is useful against bursts; a long window with a larger limit is useful against sustained use. Real products use both.
Code & diagrams
`serve` stands in for a real route; the provider call is only a log line, so this runs with no key. The extra `model` and `system` fields in the second request are accepted but never used: the server log shows its own model name. The (server log) lines appear only for requests that passed every check.
Preview
Console
Nothing logged yet.Expected console output
(server log) to provider: {"model":"provider/model-name","roles":["system","user"]}
normal 200 Orders ship in 1 to 2 working days.
(server log) to provider: {"model":"provider/model-name","roles":["system","user"]}
tries to pick the model 200 Orders ship in 1 to 2 working days.
fake system message 400 Unknown message role.
empty messages 400 Send between 1 and 20 messages.
last turn is the assistant 400 The last message must be from the user.
600 characters 400 Each message needs 1 to 500 characters.
21 turns 400 Send between 1 and 20 messages.
9,000 character body 413 That request is too large.
JSON null 400 Send between 1 and 20 messages.
not JSON 400 The request must be JSON.A pattern scanner only catches the shapes it knows. It is a safety net, not a guarantee. The real defence is that the key never enters browser code in the first place. Real secret scanners also look at your git history.
Preview
Console
Nothing logged yet.Expected console output
bundle with the key inside: [ 'key-like token', 'Bearer header built in the browser', 'call straight to a provider host' ]
bundle that calls /api/chat: clean
anyone can copy: sk-FAKE…ghijHere the visitor is named by a header the page chooses, which anyone can fake. A real route identifies the visitor from something it controls: a signed-in user id, a session cookie, or the network address as a last resort. Counters in memory also reset when the server restarts and are not shared between copies of the server.
Preview
Console
Nothing logged yet.Expected console output
t=0s visitor-A → 200
t=0s visitor-A → 200
t=0s visitor-A → 200
t=0s visitor-A → 429 | retry-after: 60
t=0s visitor-B → 200
t=45s visitor-A → 429 | retry-after: 15
t=61s visitor-A → 200Your route is a client of the provider and a server for the browser, and it must handle both failure sets. The provider's 401 (your key) and 503 (their outage) are not the visitor's business, so both become one safe 502, while the provider's 429 keeps its `retry-after` so Topic 1.3's browser code can show a wait time. The (server log) lines are what only you see. The provider is a stand-in: its 429 body names an organisation, which the browser never sees.
Preview
Console
Nothing logged yet.Expected console output
browser sees: 200 retry-after: null {"answer":"Orders ship in 1 to 2 working days."}
(server log) 429 Rate limit reached for org chai-and-co.
browser sees: 429 retry-after: 7 {"error":{"code":"rate_limited","message":"Mitra is busy."}}
(server log) 401 Incorrect API key provided.
browser sees: 502 retry-after: null {"error":{"code":"provider_error","message":"Mitra is not available right now."}}
(server log) 503 The engine is currently overloaded.
browser sees: 502 retry-after: null {"error":{"code":"provider_error","message":"Mitra is not available right now."}}
(server log) provider call failed: TimeoutError
browser sees: 504 retry-after: null {"error":{"code":"provider_timeout","message":"Mitra is taking too long. Please try again."}}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Let the browser choose the model
In the first example change callProvider({ model: MODEL, to callProvider({ model: body.model || MODEL,.
Break #2
Accept a system message from the browser
In the first example change the role check to also allow "system" (&& t.role !== "system" added to the condition).
Myth vs fact
Myth
The key is safe if I hide it in a minified or obfuscated JavaScript file.
Fact
Anything sent to the browser can be read. Minifying only makes it harder to read for a few minutes. Keep keys on the server.
Myth
CORS protects my route from other people.
Fact
CORS only restrains other websites in a browser. A script, curl or another server ignores it. Protect the route with validation, limits and sign-in.
Myth
If the page limits the input, the server does not need to.
Fact
The page can be skipped. Every limit that matters must be enforced on the server.
Myth
Deleting a leaked key from the code fixes the leak.
Fact
The key remains in git history and in every copy anyone made. Revoke it at the provider and make a new one.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Set a spending limit and an alert in the provider's dashboard as well. Your route's limits are the first fence; the provider's cap is the last one, and it protects you from your own bugs too.
- ▸
Give each environment its own key (local, preview, production). A leak from a preview deployment then costs little, and you can revoke it without taking production down. Name keys after where they are used so you can tell them apart in the dashboard.
- ▸
Return the same small error shape from every route (Topic 1.3) and log a request id. (The first three examples here keep
{ error: "text" }short to stay readable; the fourth uses the fuller{ error: { code, message } }, which is the shape to copy.) In production, "the visitor says it failed at 3 pm" is only solvable if you can find that exact request. - ▸
Think of the route as your public API even if only your page uses it today. Bots find open routes quickly. Treat unauthenticated AI routes as hostile-by-default: a signed-in user, a per-user limit and a bot check (for example a challenge) are normal for anything that costs money.
Remember this
- 1
The problem this solves: An AI feature needs a secret key to call the provider, and every call is billed to you. Code that runs in the browser can be read by anyone: open the developer tools and every line, every header and every string is there. A key in the browser is a key you have published. And a route that does whatever the browser says lets any visitor pick an expensive model, rewrite your instructions, or send a book as a question. This topic builds the route that prevents all of that.
- 2
What the proxy does. The browser sends
POST /api/chatto *your* server, with the visitor's conversation. Your route (a function that handles that request, called a route handler) validates it, adds the parts only you should control (the model name and the system message), attaches the secret key, calls the provider, and sends back a small, safe result. In Next.js this isapp/api/chat/route.tswith an exportedPOSTfunction. In the live examples,serve(path, handler)runs the same shape of function inside the preview, so you can test it with no server. - 3
Secrets live in environment variables. An environment variable is a named value set outside the code, read on the server as
process.env.PROVIDER_API_KEY. Locally you put it in a file that is never committed (.env.local, listed in.gitignore); on a host such as Vercel you set it in the project's settings. In Next.js, a variable whose name starts withNEXT_PUBLIC_is deliberately copied into the browser bundle, so a key must never have that prefix. Also never print it in logs, return it in a response, or paste it in a chat. If a key is ever exposed, revoke it at the provider and create a new one: deleting the code does not help, because it stays in git history and in anyone's copy. - 4
Validate everything that comes in. The body might not be JSON.
messagesmight be missing, huge, or have 10,000 entries. A message might have a role you never allow, or content that is a number or an object. Check the type and the size of every field and reject with400(bad shape) or413(too large) before spending a cent. The first live example runs the same route against ten kinds of requests. - 5
Do not trust the browser with decisions. If the request body can say
model: "the-most-expensive-model", any visitor can send that. If it can include asystemmessage, a visitor can write their own rules for your assistant. So the route ignores those fields completely. It uses its own constant for the model, builds the system message itself, and accepts only the rolesuserandassistantfrom the browser. Everything the visitor controls is the *content* of their own messages, and Phase 9 deals with what a hostile one can do with that. - 6
Limit size and rate. Limit the length of one message, the number of messages and the total body size. Limit how often one visitor can ask (a 429 with
Retry-After, as in the third live example). Without limits, one script can run up your bill in minutes. Real apps keep counters in a shared store (a database or cache), not in server memory, because a server can run as many copies. Phase 13 covers budgets per user. - 7
CORS in one paragraph. Browsers stop a page on one website from reading answers from another website's server, unless that server allows it with an
Access-Control-Allow-Originheader. This is called CORS (cross-origin resource sharing). Your page and your route are on the same site, so you need no CORS settings at all. Do not addAccess-Control-Allow-Origin: *to "fix" an error you do not understand. And remember CORS only restrains *browsers*: a script orcurlcan call your route directly, ignoring CORS. Your real protection is validation, limits and, where it fits, a signed-in user.
Explain it without notes
Why must the browser call your own server instead of the model provider directly?
What should a chat route validate before calling the provider?
Why does the route ignore a model or system field sent by the browser?
Does CORS protect your route from misuse? Explain.
Practice
Add a check to the first example's route that rejects a request if the same message text appears more than 5 times in the conversation. What attack or accident does this guard against?
Write the .gitignore line and the code that reads the key and fails loudly at startup if it is missing, without printing it.
Trade-offs
- ↔
A proxy route adds a hop and a little latency, and gives you validation, secrecy, logging and control. For anything that costs money it is not optional.
- ↔
Strict limits protect your bill and annoy a few real users. Start strict, watch the rejection logs, and loosen with evidence.
Done when you can
I can explain why the secret key stays on the server and where it is stored.
I can write a route that validates type, size, roles and count before calling the provider.
I can name what the route must decide itself, and not take from the browser.
I can add a per-visitor limit that returns 429 with a
Retry-After.I can say what CORS does and what it does not protect.