hashbrown

Securing a Public Endpoint

Pin the system prompt, tools and UI response schema on the server, and bound every request to a public chat endpoint.

This recipe uses the server of atc, Hashbrown's example app, as the worked example. The server code below is from examples/atc/server/src/run-handler.ts.


The browser is untrusted

useUiChat sends everything the model needs in each run request: the messages, the system prompt, the tools and the UI response schema. Your app builds that request, but anyone can send any request to your endpoint with curl. A server that forwards the request as it arrives lets a stranger use your provider key for any prompt, any tools and any output.

The fix is to decide on the server what the model sees. Treat the rest of the request as untrusted, including earlier assistant turns and tool results: tools run in the browser, so the server forwards those messages to the model, and an attacker can forge them. Pinning cannot stop a forged message. It bounds what one can do: the model still runs under your prompt, with only your tools and your schema, and every request is capped in size, output and rate.


Pin what the model sees, on the server

The system prompt

Drop every system and developer message the browser sent, and put the server's prompt first:

run-handler.ts
export function pinSystemPrompt(
  input: RunAgentInput,
  prompt: string,
): RunAgentInput {
  return {
    ...input,
    messages: [
      { id: 'atc-system', role: 'system', content: prompt },
      ...input.messages.filter(
        (message) => message.role !== 'system' && message.role !== 'developer',
      ),
    ],
  };
}

The tools

Tools run in the browser, so the browser decides which tools a run has. The server decides what each one says. Build each tool as the model sees it from the same definitions the browser uses, then replace every tool the browser sent with the server's copy and drop the ones the server does not know:

run-handler.ts
const PINNED_TOOLS: ReadonlyMap<string, Tool> = new Map(
  Object.values(ATC_TOOL_DEFINITIONS).map(
    ({ name, description, schema }): [AtcToolName, Tool] => [
      name,
      // The same conversion Hashbrown's client applies to a tool's schema.
      { name, description, parameters: s.toJsonSchema(schema) },
    ],
  ),
);

export function pinTools(tools: readonly Tool[]): Tool[] {
  const names = new Set(tools.map((tool) => tool.name));

  return [...names].flatMap((name) => {
    const pinned = PINNED_TOOLS.get(name);

    return pinned === undefined ? [] : [pinned];
  });
}

The UI response schema

The response schema lists the components the model may render and their props. Build it on the server with createUiJsonSchema from @hashbrownai/core, passing the same component definitions, in the same order, that the browser exposes. exposeMarkdown builds from markdownComponent(), so pass that too, with the same name, description and citations options if you set any:

run-handler.ts
import type { RunAgentInput } from '@ag-ui/core';
import {
  aircraftCompareContract,
  airportCardContract,
  arrivalsBoardContract,
  flightCardContract,
} from '@atc/shared';
import { createUiJsonSchema, markdownComponent } from '@hashbrownai/core';
import type { OpenAIHashbrownRunAgentInput } from '@hashbrownai/openai';

export const PINNED_RESPONSE_SCHEMA = createUiJsonSchema({
  components: [
    markdownComponent(),
    { ...flightCardContract, children: false },
    { ...arrivalsBoardContract, children: false },
    { ...aircraftCompareContract, children: false },
    { ...airportCardContract, children: false },
  ],
});

const PINNED_RESPONSE_SCHEMA_JSON = JSON.stringify(PINNED_RESPONSE_SCHEMA);

export function pinResponseSchema(input: RunAgentInput): RunAgentInput {
  const { hashbrown } = input as OpenAIHashbrownRunAgentInput;
  if (hashbrown?.responseSchema === undefined) {
    return input;
  }
  if (
    JSON.stringify(hashbrown.responseSchema) !== PINNED_RESPONSE_SCHEMA_JSON
  ) {
    console.warn(
      '[atc] replaced a response schema that differs from the pinned one',
    );
  }

  return {
    ...input,
    hashbrown: { ...hashbrown, responseSchema: PINNED_RESPONSE_SCHEMA },
  } as OpenAIHashbrownRunAgentInput;
}

The schema is pinned when the browser sends one. A request that leaves out hashbrown.responseSchema reaches the model with no schema and gets free-form text back, still under the pinned prompt, the pinned tools, the output cap and the rate limit.

Share the definitions

The browser and the server must agree byte for byte, or every request logs a difference. atc keeps the component contracts, the tool definitions and the system prompt in a shared package (@atc/shared). The browser exposes each component from its contract:

assistant.tsx
const components = [
  exposeMarkdown({ className: 'atc-prose' }),
  exposeComponent(FlightCard, {
    name: flightCardContract.name,
    description: flightCardContract.description,
    props: flightCardContract.props,
    fallback: FlightCardFallback,
    children: false,
  }),
  // ...the other three components
];

The server applies all three pins before calling the model:

run-handler.ts
const stream = HashbrownOpenAI.stream.text({
  apiKey: options.apiKey,
  baseURL: options.baseURL,
  model: options.model,
  input: pinSystemPrompt(
    pinResponseSchema({ ...input, tools: pinTools(input.tools ?? []) }),
    SYSTEM_PROMPT,
  ),
  signal: abortController.signal,
  transformRequestOptions: (request) => limitRequest(request, reasoningEffort),
});

Replace, don't reject

Each pin replaces what the browser sent instead of returning an error. A rejection stops no attacker, who just sends the expected values, and it breaks real users still running an older build of your app after a deploy. Log the difference instead, as pinResponseSchema does, and never log the payload itself: it is untrusted and can be large.

Replacing keeps an older build working only while the component set is unchanged. After a deploy that adds, renames or changes a component, an open tab on the old build can receive output it does not know how to render, so keep component changes backwards compatible or ask open tabs to reload.


Bound each request

Pinning fixes what the model sees. Limits fix what a request can cost. atc caps the request body at 256 KiB, and the number and size of messages:

run-handler.ts
const MAX_MESSAGES = 100;

const MAX_MESSAGE_CHARS = 16_000;

function isTooLarge(input: RunAgentInput): boolean {
  return (
    input.messages.length > MAX_MESSAGES ||
    input.messages.some(
      (message) => JSON.stringify(message).length > MAX_MESSAGE_CHARS,
    )
  );
}

A request over either limit gets a 413 before it reaches the provider. limitRequest caps the answer's length on the provider request itself, through transformRequestOptions:

run-handler.ts
import type OpenAI from 'openai';

type ReasoningEffort = NonNullable<
  OpenAI.Chat.ChatCompletionCreateParams['reasoning_effort']
>;

/** gpt-5 and the o-series accept `reasoning_effort`; other models reject it with a 400. */
const REASONING_MODEL = /^(gpt-5|o\d)/;

const MAX_OUTPUT_TOKENS = 4096;

export function limitRequest(
  request: OpenAI.Chat.ChatCompletionCreateParamsStreaming,
  reasoningEffort: ReasoningEffort | null,
): OpenAI.Chat.ChatCompletionCreateParamsStreaming {
  return {
    ...request,
    max_completion_tokens: MAX_OUTPUT_TOKENS,
    ...(reasoningEffort !== null && REASONING_MODEL.test(request.model)
      ? { reasoning_effort: reasoningEffort }
      : {}),
  };
}

Set the model on the server too. atc reads it from OPENAI_MODEL and ignores anything the browser sends.


Outside the code

  • Rate-limit the route. atc's hosted app allows 40 requests a minute per IP on POST /api/run, set as a Vercel Firewall rule. One question makes 3 to 5 requests, one per tool round trip.
  • Cap the provider key's budget. Use a dedicated project key with a hard monthly budget, so a bug or an abuser cannot spend past it.

See it in atc

Read the full handler, its tests and the shared definitions in examples/atc, starting at server/src/run-handler.ts.

Securing a Public EndpointThe browser is untrustedPin what the model sees, on the serverThe system promptThe toolsThe UI response schemaShare the definitionsReplace, don't rejectBound each requestOutside the codeSee it in atc