Adding an AI chat box to a website is a five-minute job — and most five-minute tutorials get the one important thing wrong: they put an API key in the front-end. This walkthrough uses a tiny, dependency-free widget that refuses to let you do that, and shows the two correct ways to handle the key depending on whether the page is yours or public.
The widget is ~11 KB of vanilla JS — no build step, no framework — and it talks to any OpenAI-compatible Chat Completions endpoint. I'll use daoxe as the default (disclosure below), but it's one line to point it anywhere.
Disclosure: I work on daoxe, the widget's default endpoint. Nothing here is locked to it — change one line and it talks to any compatible endpoint.
The uncomfortable truth: anything in the browser is public
An LLM API key is a billing credential. Anything you ship to a browser — including a key in your JS bundle — is readable by every visitor. So the widget deliberately has no apiKey option in its config. Instead you pick one of two modes:
| Mode | Who holds the key | Use it for | Trade-off |
|---|---|---|---|
byok (default) |
the visitor pastes their own key; kept only in their browser | a personal/local page only you use; dashboards; internal tools | needs the endpoint to allow browser CORS |
proxy (recommended for public sites) |
your server injects the key; the browser never sees it | any public website | you run a tiny proxy (examples included) |
That single design choice is the whole point. If a widget lets you drop your own key into a public page's source, it's shipping your credential to the world.
A) BYOK mode — for a page that is yours
For a personal dashboard or an internal tool, the visitor supplies their own key, stored only in their browser's localStorage and sent straight to the endpoint:
<script src="daoxe-widget.js"></script>
<script>
DaoxeChat.init({
baseUrl: "https://daoxe.com/v1",
model: "YOUR_DAOXE_MODEL_ID", // exact id from GET /v1/models
title: "Ask me anything"
});
</script>
This only works if the endpoint returns permissive CORS headers (many gateways don't enable CORS on /chat/completions — that's what proxy mode is for). If you see a "network/CORS" error, switch to proxy.
B) Proxy mode — for public sites
For anything public, the browser talks to your route, and your server adds the key:
<script src="daoxe-widget.js"></script>
<script>
DaoxeChat.init({
mode: "proxy",
proxyUrl: "/api/chat", // a route you control that adds the key
model: "YOUR_DAOXE_MODEL_ID",
title: "Ask us anything"
});
</script>
Ready-to-run proxies ship in examples/:
- Cloudflare Worker — deploy in minutes; key stored as a Worker secret.
-
Node/Express — reads
DAOXE_API_KEYfrom the environment and streams straight through.
Both keep the key server-side and can optionally allow-list which models the proxy will forward. In production, restrict Access-Control-Allow-Origin to your domain.
Visitor ──▶ /api/chat (your proxy, holds key) ──▶ https://daoxe.com/v1/chat/completions
◀── streamed tokens ◀───────────────────
Security details worth knowing
A chat widget renders untrusted model output into your page, so two things matter beyond the key:
-
The widget escapes all model output before rendering — it doesn't
innerHTMLraw model text. A tiny markdown-ish renderer handles code/bold/newlines on already-escaped text, and it does not execute HTML returned by the model. (If you build your own widget, do this — model output is untrusted input.) - Lock down the proxy. Restrict CORS to your domain and optionally allow-list models so a leaked proxy URL can't be used to run arbitrary expensive models on your bill.
Configuration highlights
DaoxeChat.init({ ... }) takes the usual knobs — title, subtitle, greeting, accent, position, startOpen, systemPrompt, and (BYOK only) temperature / maxTokens. Two worth calling out:
-
modelis required — an exact id available to the key/account. Don't hardcode a list from a blog post; the authoritative list isGET {baseUrl}/models. -
storageKeycontrols the localStorage key for BYOK; set it tonullto never persist the visitor's key.
The widget is minimal by design: no file uploads, no image input, no tool calls, no cross-page persistence. Streaming uses the standard OpenAI SSE format and falls back to a single JSON read if an endpoint doesn't stream.
Why an OpenAI-compatible endpoint helps here
Because the widget speaks the standard Chat Completions shape, the same embed can serve GPT, Claude, Gemini or DeepSeek by changing a model id — one key, one bill, and you can repoint baseUrl to any provider you trust and verify. daoxe is the default (https://daoxe.com/v1, one key across many models, native Anthropic Messages too) and is not available in mainland China — but the widget privileges nothing; baseUrl and the "Powered by" link are one-line changes.
TL;DR
- One
<script>tag, ~11 KB, no build, any OpenAI-compatible endpoint. - Never put your key in front-end code. Use BYOK (your pages) or proxy (public sites).
- Proxy examples included (Cloudflare Worker, Node/Express); lock CORS to your domain in prod.
- Escape model output; require an exact
modelid from/v1/models.
Top comments (1)
I like that the article treats API keys as billing credentials instead of secrets that somehow become safe in the browser. Separating BYOK from proxy mode is a good design choice, and escaping model output is another detail that many examples skip.
One thing I’d also consider for production is protecting the proxy itself. Even if the API key never reaches the client, a public proxy can still be abused unless it has authentication, rate limiting, request validation, quotas, and logging. Moving the key server-side is the first step, but preventing the proxy from becoming an unintended public LLM gateway is just as important.