The Browser Sends Data, Never Instructions
Job three is the one people skip, having done the credential part and declared victory.
If the browser sends the prompt, you have kept the key and lost everything else. The rules from Chapter 3 are gone, the scope clause is gone, and the user can ask your budget to write them a novel.
So the browser sends parameters, and the server composes the prompt:
$validated = $request->validate([
'language' => ['required', 'string', 'in:en,es,de,it,pt'],
'content' => ['required', 'array'],
]);
$translated = (new TranslateContentAction)(
token: $workspaceToken,
content: $content,
language: (string) $validated['language'],
);
The target language is an enumeration, not a string. Not because an unexpected language is dangerous in itself, but because an open string field on a prompt-composing endpoint is a text box aimed at your model. in:en,es,de,it,pt costs nothing and closes it.
That is the general shape: every value the browser contributes should be a bounded type — an enum, an id you look up, a field key you recognise, an integer with a range. Free text is acceptable only where the feature genuinely is free text, and there it is content, interpolated into a prompt whose instructions you wrote.
Resolve the Paying Credential Server-Side
The credential that actually reaches the provider is resolved from the authenticated context and never travels outward:
$workspaceToken = $site->server?->workspace?->token;
if (! is_string($workspaceToken) || $workspaceToken === '') {
return new ErrorResponse('Workspace is not configured for AI.', Response::HTTP_SERVICE_UNAVAILABLE);
}
This is Chapter 1's per-tenant credential arriving where it matters. The browser authenticated as a site; the site resolves to a workspace; the workspace holds the credential that gets billed. The chain runs server-side from end to end, and at no point does the browser hold anything that works anywhere else.
The 503 is deliberate too. An unconfigured workspace is not the caller's fault and not a permission problem — it is us, temporarily unable to serve. Returning 401 there would send a merchant hunting for a login problem that does not exist.
What the Browser Is Still Allowed to Do
This chapter has a title that overstates its own rule, so let me be precise about the boundary.
Forbidden: the browser holding a provider credential, or composing the instructions that a model executes on your account.
Perfectly fine: the browser making a long-lived, direct HTTP call to your endpoint, including one that streams. There are good reasons to do exactly that — a CDN or proxy in front of your app may impose a first-byte timeout shorter than the work takes, and having the browser call an endpoint that begins emitting immediately is sometimes the only way through. Chapter 16 covers a case where that was the right answer.
The distinction is not how many hops there are. It is who decides. If the decisions about credential, cost, prompt and identity are made on your server, the shape of the transport is an engineering choice. If any of those four move into the client, no amount of proxying gets them back.
Two patterns follow from that:
Scoped ephemeral credentials are fine; provider keys are not. A short-lived token that authorises one narrow operation against your API is a good design. A provider key with your whole account behind it is not, however short-lived you make it.
A relay is not a boundary. An endpoint that accepts a body and forwards it to the provider has added a hop and no safety. If your proxy does not authenticate, meter, and compose, it is a redirect with extra latency.
What This Chapter Argued
- Four things leave your control when the browser calls a model directly: the credential, the spend, the prompt, and identity. The last two are the ones people forget.
- Authentication must happen where the facts live. A service that does not own the table cannot validate the token — handing it one only makes the endpoint look protected.
- A backend-for-frontend has four jobs: authenticate, meter, compose, and call outward with a credential the client never sees. Do three of them and you have a relay.
- Credentials belong in headers. URLs leak into logs, history,
Referer, analytics, and pasted links. - Treat a missing expiry as invalid, not unlimited.
- Give every authentication failure the same message. Distinguishing them is a free oracle for an attacker.
- Return a union type from the guard so the failure path cannot be forgotten, and eager-load what the caller will need next.
- The browser sends bounded parameters; the server composes the prompt. An open string field on a prompt-composing endpoint is a text box aimed at your model.
- The rule is about who decides, not how many hops there are. Direct browser calls to your own endpoints are fine — direct calls to the provider are not.