DEV Community

Cover image for Add an AI chat widget to any website — without leaking your API key
Seven
Seven

Posted on

Add an AI chat widget to any website — without leaking your API key

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Ready-to-run proxies ship in examples/:

  • Cloudflare Worker — deploy in minutes; key stored as a Worker secret.
  • Node/Express — reads DAOXE_API_KEY from 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 ◀───────────────────
Enter fullscreen mode Exit fullscreen mode

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 innerHTML raw 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:

  • model is required — an exact id available to the key/account. Don't hardcode a list from a blog post; the authoritative list is GET {baseUrl}/models.
  • storageKey controls the localStorage key for BYOK; set it to null to 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 model id from /v1/models.

Top comments (1)

Collapse
 
merbayerp profile image
Mustafa ERBAY

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.