Documentation
Bring your own key: LLM apps and Supabase
A host0 app can call an LLM or your own Supabase project from the browser today, with no server code. It can't keep a secret, so this page draws the line: what's safe, what isn't, and a chat app you can publish as it is.
The safety line
Every file in an app goes to the browser of whoever opens it. A private app only narrows who that is: anyone you invite can still read its JavaScript. So a key in your code is a published key.
| Key | Where it lives | Today |
|---|---|---|
| A visitor's own OpenAI, Anthropic or OpenRouter key | The visitor pastes it; it stays in their browser | Works. Their key, their bill |
Supabase publishable key (sb_publishable_…) or legacy anon key | In your bundle, in the open | Works. Row Level Security is what protects the data |
| Your own OpenAI, Anthropic or OpenRouter key | Never in the bundle | Needs a server-side proxy, which host0 doesn't have yet |
Supabase secret key (sb_secret_…) or legacy service_role key | Never in the bundle | It ignores Row Level Security. Keep it on a server |
host0 refuses a deploy with files that look like credentials (.env, .pem,
.key, id_rsa), but it doesn't read your JavaScript. A key pasted into
app.js goes live with no warning, and anyone can use it until you revoke it.
If everyone should use your key: host0 can't do that yet. A proxy that keeps the key on the platform is planned, not shipped. Until then, put the key in a backend you run elsewhere (a Supabase Edge Function or a Cloudflare Worker) and call that from the app. It needs its own sign-in check and rate limit, or strangers spend your money through it.
Visitors bring their own key
The page asks the visitor for their key and calls the provider straight from their browser. host0 never sees the key or the conversation: the request goes from the visitor's browser to the provider, not through host0.
All three providers accept these calls from another origin. We checked the
preflight response of each endpoint from a *.host0.app origin on 5 November 2026.
| Provider | Endpoint | Headers |
|---|---|---|
| Anthropic | POST https://api.anthropic.com/v1/messages | x-api-key, anthropic-version, anthropic-dangerous-direct-browser-access: true |
| OpenAI | POST https://api.openai.com/v1/responses | authorization: Bearer … |
| OpenRouter | POST https://openrouter.ai/api/v1/chat/completions | authorization: Bearer … |
Be straight with visitors about the risk. A key typed into a page can be read by every script on that page, which is why OpenAI's key safety guide says not to deploy a key in client-side code, and why Anthropic's header has "dangerous" in its name. That advice is about shipping a key, not about a visitor choosing to paste theirs, but the exposure is the same. So:
- Ask for a dedicated key with a spending limit, and say so on the page. OpenRouter is the gentlest choice: credit limits per key, and an OAuth flow that gives the visitor a key in one click instead of a paste.
- Keep the key in memory, or in
sessionStorageif the visitor asks. AvoidlocalStorage: it outlives the tab, and when a slug is renamed or deleted the address is released after 30 days, so its next owner gets the same origin. - Never put the key anywhere else: not in a URL (browser history, logs and
referrers keep URLs), not in shared records, not in
h0.json. - Load no third-party scripts on that page, and lock it down with a Content-Security-Policy, as the example below does. host0's badge and analytics scripts are same-origin and don't read form fields.
- Treat model output as untrusted. Render it with
textContent, neverinnerHTML.
A worked example: an LLM chat app
One file, no build step, no h0.json. Save it as chat/index.html, then publish
the folder.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- The key can only travel to these three hosts. -->
<meta http-equiv="content-security-policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.anthropic.com https://api.openai.com https://openrouter.ai">
<title>Chat with your own key</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; color-scheme: light dark; }
form { display: flex; gap: .5rem; flex-wrap: wrap; margin: 1rem 0; }
input, select, button { font: inherit; padding: .4rem .6rem; }
#key, #prompt { flex: 1 1 14rem; }
#log p { white-space: pre-wrap; margin: .75rem 0; }
#log .user { font-weight: 600; }
#error { color: #c00; }
small { opacity: .7; }
</style>
</head>
<body>
<h1>Chat with your own key</h1>
<p><small>Your key stays in this browser and goes only to the provider you pick. It is not sent to this site. Use a dedicated key with a spending limit.</small></p>
<form id="settings">
<select id="provider" aria-label="Provider">
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
</select>
<input id="model" aria-label="Model">
<input id="key" type="password" autocomplete="off" spellcheck="false" placeholder="Your API key" aria-label="API key">
<label><input id="remember" type="checkbox"> Remember in this tab</label>
</form>
<div id="log"></div>
<p id="error" role="alert"></p>
<form id="chat">
<input id="prompt" placeholder="Ask something" aria-label="Message" required>
<button id="send">Send</button>
</form>
<script>
const providers = {
anthropic: {
url: "https://api.anthropic.com/v1/messages",
model: "claude-opus-5-5",
headers: (key) => ({
"x-api-key": key,
"anthropic-version": "2026-10-21",
// Anthropic's opt-in for calls from a browser, where the page can read the key.
"anthropic-dangerous-direct-browser-access": "true",
}),
body: (model, messages) => ({ model, messages, max_tokens: 16000, stream: true }),
text: (e) => (e.delta?.type === "text_delta" ? e.delta.text : ""),
},
openai: {
url: "https://api.openai.com/v1/responses",
model: "gpt-6.1-sol",
headers: (key) => ({ authorization: `Bearer ${key}` }),
body: (model, messages) => ({ model, input: messages, stream: true }),
text: (e) => (e.type === "response.output_text.delta" ? e.delta : ""),
},
openrouter: {
url: "https://openrouter.ai/api/v1/chat/completions",
model: "openrouter/auto",
headers: (key) => ({ authorization: `Bearer ${key}` }),
body: (model, messages) => ({ model, messages, stream: true }),
text: (e) => e.choices?.[0]?.delta?.content ?? "",
},
};
const $ = (id) => document.getElementById(id);
const messages = []; // the conversation lives in memory: gone when the tab closes
$("key").value = sessionStorage.getItem("key") ?? "";
$("remember").checked = Boolean($("key").value);
$("provider").value = sessionStorage.getItem("provider") ?? "anthropic";
$("model").value = providers[$("provider").value].model;
$("provider").onchange = () => { $("model").value = providers[$("provider").value].model; };
function remember() {
if ($("remember").checked) {
sessionStorage.setItem("key", $("key").value);
sessionStorage.setItem("provider", $("provider").value);
} else {
sessionStorage.clear();
}
}
function addLine(text, className) {
const p = document.createElement("p");
p.className = className;
p.textContent = text; // textContent, never innerHTML: model output is untrusted
$("log").append(p);
return p;
}
function failure(event) {
if (event.delta?.stop_reason === "refusal") return "The model declined this request.";
return event.error?.message ?? event.response?.error?.message ??
(event.type === "error" ? event.message : null);
}
async function ask(provider, key, model) {
const res = await fetch(provider.url, {
method: "POST",
headers: { "content-type": "application/json", ...provider.headers(key) },
body: JSON.stringify(provider.body(model, messages)),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error?.message ?? `The provider answered ${res.status}.`);
}
// Each provider streams server-sent events: lines of `data: {json}`.
const line = addLine("", "assistant");
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let reply = "";
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) return reply;
buffer += value;
const lines = buffer.split("\n");
buffer = lines.pop();
for (const raw of lines) {
if (!raw.startsWith("data:")) continue;
const data = raw.slice(5).trim();
if (data === "[DONE]") continue;
const event = JSON.parse(data);
const problem = failure(event);
if (problem) throw new Error(problem);
reply += provider.text(event);
line.textContent = reply;
}
}
}
$("chat").onsubmit = async (e) => {
e.preventDefault();
const key = $("key").value.trim();
$("error").textContent = "";
if (!key) { $("error").textContent = "Paste your API key first."; return; }
remember();
const text = $("prompt").value;
$("prompt").value = "";
messages.push({ role: "user", content: text });
addLine(text, "user");
$("send").disabled = true;
try {
const reply = await ask(providers[$("provider").value], key, $("model").value.trim());
if (reply) messages.push({ role: "assistant", content: reply });
} catch (err) {
messages.pop(); // so the same question can be asked again
$("error").textContent = err.message;
} finally {
$("send").disabled = false;
}
};
</script>
</body>
</html>Publish it with the skill, or ask your agent to:
./scripts/publish.sh ./chat --name "Chat" --visibility linkWhat we verified, and what we didn't. On 5 November 2026 we published this exact
file to a local host0 stack and opened it in a browser. It loaded with the badge and
analytics scripts and no Content-Security-Policy errors. With a fake key, each
provider's real endpoint passed the browser's cross-origin check, answered 401, and
the page showed the error. With fetch stubbed, a canned stream in each provider's
format rendered as plain text, and a refusal and a mid-stream error showed as messages.
We never used a real key, so no model has answered through this page. The defaults
for model and anthropic-version come from each provider's docs that day. They age,
so the model box is editable.
Your own Supabase project
An app can use Supabase straight from the browser. The publishable key is made to ship in client code, and Row Level Security decides what it can reach: Supabase's guide says so plainly, and says to enable RLS on every table before you deploy.
Pick this over host0's own shared data when you need SQL,
joins, a second app on the same data, or your own sign-in. For a list or a tracker
that a team shares, /.h0/data is less work: no project, no policies, and sign-in
is already there.
First, in your Supabase project's SQL editor, a table only its owner can touch:
create table public.notes (
id uuid primary key default gen_random_uuid(),
user_id uuid not null default auth.uid() references auth.users on delete cascade,
body text not null,
created_at timestamptz not null default now()
);
alter table public.notes enable row level security;
revoke all on table public.notes from anon, authenticated;
grant select, insert, delete on table public.notes to authenticated;
create policy "Read own notes" on public.notes for select to authenticated
using ((select auth.uid()) = user_id);
create policy "Add own notes" on public.notes for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "Delete own notes" on public.notes for delete to authenticated
using ((select auth.uid()) = user_id);Then, in the app, with your project's URL and publishable key:
<script type="module">
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
// Both are public by design. RLS protects the data, not this key.
const supabase = createClient(
"https://YOUR-PROJECT.supabase.co",
"sb_publishable_YOUR_KEY",
);
const { data, error } = await supabase
.from("notes")
.select("id, body, created_at")
.order("created_at", { ascending: false });
</script>Three things catch people out:
- No RLS means public. A table without RLS can be read and changed by anyone holding the publishable key, which is everyone who opens the app. Test your policies signed out and as a second user.
- Sign-in is separate from host0's. Supabase Auth doesn't know who
/.h0/meis, and the reverse. If you use email links or Google, add your app's URL (https://k3x9q2m7ta.host0.app/) to the redirect URLs under Authentication → URL Configuration, and change it when you rename the app. - Supabase's own limits and bill are yours. host0 doesn't see or manage that project: back it up and watch its usage in Supabase.
We ran the SQL above on local Postgres on 5 November 2026 and confirmed that a signed-in user reads only their own rows and a signed-out visitor is refused. We didn't run the browser snippet against a live Supabase project.