Skip to content

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.

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)

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.

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.

  • 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.