Backend-only tables
By default every database table is app-visible: any credential that can reach your app — including the browser-safe publishable key — can read and write it. That’s the right default for data the client owns.
Some data shouldn’t be directly reachable from the browser at all: audit logs, internal scoring, other users’ records, anything you want to gate behind your own authorization logic. Mark those tables backend-only.
What “backend-only” means
Section titled “What “backend-only” means”Each table has an access class:
app(default) — reachable by any app credential, including a publishable key (pk). The normal client data plane.backend— a publishable key is rejected outright (403). The table is reachable only with a non-publishable credential: a scoped access token (at) or a secret key (sk).
Because the browser only ever holds a pk, a backend-only table is structurally
unreachable from client code. The only path to it is server-side — a
serverless function you write, which applies its own
authorization and acts as the gateway.
browser (pk) ──✗──▶ backend table (403: publishable key rejected)browser (pk) ──▶ function (at) ──▶ backend table (function gates access)Marking a table backend-only
Section titled “Marking a table backend-only”In the developer portal, open your app → Database → the table → set its Visibility to Backend-only. Existing tables default to app-visible, so nothing changes until you opt a table in.
Reading it from a function
Section titled “Reading it from a function”A function receives a scoped access token automatically as
env.MUHKOO_ACCESS_TOKEN (mirrored to env.MUHKOO_APP_KEY), so it can reach
backend tables with no manual key wiring. The token carries db:read and
db:write. A function is a single module with no build step, so call the
database’s REST routes through the MUHKOO_API binding rather than importing
the SDK (see The function
environment):
export default { async fetch(request, env) { const api = (path, init) => env.MUHKOO_API.fetch(env.MUHKOO_API_URL + path, init); const headers = { "Content-Type": "application/json", "X-Muhkoo-Key": env.MUHKOO_APP_KEY };
// 1) Apply YOUR authorization first: verify the caller's session. const verify = await api("/api/auth/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: request.headers.get("X-Muhkoo-Session") }), }); if (!verify.ok) return new Response("Unauthorized", { status: 401 }); const { commitment } = await verify.json();
// 2) The injected token reaches backend-only tables the browser can't. const res = await api("/api/db/audit_log/query", { method: "POST", headers, body: JSON.stringify({ where: [{ column: "user", op: "eq", value: commitment }], limit: 50 }), }); return Response.json(await res.json()); // { rows, nextCursor } },};The routes are the ones client.db wraps: POST /api/db/<table> (insert),
POST /api/db/<table>/query, and GET / PATCH / DELETE on
/api/db/<table>/<id>. The access token is minted per function and rotated
roughly weekly, so read it from env on each request.
A browser calling the same table directly with the app’s publishable key gets a
403 — exactly the gate you want.
When to use it
Section titled “When to use it”- Sensitive or cross-user data — records a client must never read wholesale.
- Server-authored data — audit trails, computed scores, moderation state.
- Anything gated by custom rules — put the rules in a function and make the table backend-only so the function is the only way in.
For data the client legitimately owns and queries directly, leave the table app-visible — backend-only adds a round-trip through your function.