Command Palette

Search for a command to run...

Hectal
PHASE 1Beginner ~34 min· topic 5 of 6

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 a Request and returns a Response.
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 with process.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.)

.env.local (never committed)whole filebash
PROVIDER_API_KEY=paste-your-key-here
# .gitignore must contain:  .env*.local

02The 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.

app/api/chat/route.tswhole filets
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.

Limit how oftendiagram
Rendering diagram…

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. 1

    Tighten a limit

    In the first example change MAX_TEXT to 20 and 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. 2

    Add a key to the bundle

    In the second example add a line const KEY = "sk-ANOTHERFAKE0123456789abcdef"; to bundleDoneRight.

    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. 3

    Change the window

    In the third example set LIMIT to 1 and WINDOW_MS to 10_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

A route that distrusts the browser

`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

Press Run to see the page.

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.
Scanning shipped code for a secret

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

Press Run to see the page.

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…ghij
A limit per visitor, with a wait time

Here 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

Press Run to see the page.

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 → 200
The route when the provider itself fails

Your 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

Press Run to see the page.

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,.

terminal
$ Console
── what you'll see ──
(server log) to provider: {"model":"the-most-expensive-model","roles":["system","user"]}

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).

terminal
$ Console
── what you'll see ──
(server log) to provider: {"model":"provider/model-name","roles":["system","system","user"]}
fake system message 200 Orders ship in 1 to 2 working days.

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. 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. 2

    What the proxy does. The browser sends POST /api/chat to *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 is app/api/chat/route.ts with an exported POST function. 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. 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 with NEXT_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. 4

    Validate everything that comes in. The body might not be JSON. messages might 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 with 400 (bad shape) or 413 (too large) before spending a cent. The first live example runs the same route against ten kinds of requests.

  5. 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 a system message, 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 roles user and assistant from 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. 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. 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-Origin header. 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 add Access-Control-Allow-Origin: * to "fix" an error you do not understand. And remember CORS only restrains *browsers*: a script or curl can call your route directly, ignoring CORS. Your real protection is validation, limits and, where it fits, a signed-in user.

Explain it without notes

01

Why must the browser call your own server instead of the model provider directly?

02

What should a chat route validate before calling the provider?

03

Why does the route ignore a model or system field sent by the browser?

04

Does CORS protect your route from misuse? Explain.

Practice

01

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?

02

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.