AgentDBDocs

Security for apps

This page shows how to build an app on AgentDB without leaking access to your data. Read it before you write code. It applies to every language and framework.

#The rule

Never put an AgentDB API key in code that runs on someone else's device.

That means no key in:

  • HTML, JavaScript or CSS that a browser loads;
  • a client bundle built by React, Vue, Svelte, Angular, Next.js or Vite;
  • a mobile app, desktop app or browser extension;
  • a public Git repository, a screenshot or a support ticket.

This applies to every kind of key: account keys, workspace keys and agent keys. All of them start with adbk..

#Why a key in the browser is a problem

Anyone can open the developer tools of their browser and read every request a page makes. A key in a page is therefore public.

A key acts with the full rights of its owner:

  • An account key reaches every workspace of your account.
  • An agent key reaches one workspace, with the role of its agent.
  • Neither key knows who your end users are.

AgentDB has no tokens for your end users. It cannot limit a key to "the orders of customer 42". So do not call the AgentDB REST API from a browser with a key. That check belongs in your own backend.

The workspace REST API also sends no CORS headers. A browser on your website cannot read its answers. Do not work around this with a proxy that adds the key for anyone who asks: that proxy is the same leak.

#Wrong and secure, side by side

The wrong way puts the key in the page. Anyone who opens the page can copy it and read, change or delete your data.

JavaScript: wrong, do not copy
// app.js, loaded by the browser. DO NOT DO THIS.
const res = await fetch("https://app.agentdb.link/v1/workspaces/ws_example/query", {
  method: "POST",
  headers: {
    Authorization: "Bearer adbk.YOUR-KEY", // visible to every visitor
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ object: "contact", limit: 20 }),
});

// The same leak, hidden by a build tool: NEXT_PUBLIC_ and VITE_ variables end up in the bundle.
const key = process.env.NEXT_PUBLIC_AGENTDB_KEY;

The secure way splits the work in two. The browser calls your backend, without any key. Your backend checks the person and calls AgentDB with a key from its environment.

Browser code (no key, no AgentDB address):

JavaScript: secure, do thisexamples/languages/javascript/live/public/app.js
const list = document.getElementById("list");
const status = document.getElementById("status");

async function refresh() {
  const res = await fetch("/api/contacts", { credentials: "same-origin" });
  if (!res.ok) return void (status.textContent = "Could not load contacts.");
  const rows = await res.json();
  list.replaceChildren(
    ...rows.map((r) => {
      const li = document.createElement("li");
      li.textContent = `${r.name}${r.score === null ? "" : ` (${r.score})`}`; // text, never HTML
      return li;
    }),
  );
  status.textContent = `Updated ${new Date().toLocaleTimeString()}`;
}

const events = new EventSource("/events"); // reconnects by itself
events.addEventListener("change", () => void refresh());
events.addEventListener("open", () => void refresh());
events.addEventListener("error", () => (status.textContent = "Reconnecting…"));

Backend code (holds the key, decides what to return):

JavaScript: secure, do thisexamples/languages/javascript/live/server.mjs
const BASE_URL = (process.env.AGENTDB_URL ?? "https://app.agentdb.link").replace(/\/+$/, "");
const API = `${BASE_URL}/v1/workspaces/${process.env.AGENTDB_WORKSPACE}`;
const HEADERS = { Authorization: `Bearer ${process.env.AGENTDB_API_KEY}` }; // stays on the server

async function agentdb(path, init = {}) {
  const res = await fetch(API + path, { ...init, headers: { ...HEADERS, ...init.headers } });
  const body = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
  return body.data;
}
JavaScript: secure, do thisexamples/languages/javascript/live/server.mjs
/** Your own sign-in goes here (check a session cookie, SSO, ...). This demo lets everyone read. */
function authorize(req) {
  return req.method === "GET";
}

const PUBLIC = fileURLToPath(new URL("./public/", import.meta.url));

async function handle(req, res) {
  if (!authorize(req)) return res.writeHead(401).end();
  if (req.url === "/events") {
    res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
    res.write(": connected\n\n");
    clients.add(res);
    req.on("close", () => clients.delete(res));
    return;
  }
  if (req.url === "/api/contacts") {
    const page = await agentdb("/query", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ object: "contact", sort: [{ field: "updated_at", order: "desc" }], limit: 20, fields: ["name", "score"] }),
    });
    const rows = page.records.map((r) => ({ id: r.id, name: r.data.name, score: r.data.score ?? null }));
    res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
    return res.end(JSON.stringify(rows)); // only the fields the page needs
  }
  const file = { "/": "index.html", "/app.js": "app.js" }[req.url];
  if (!file) return res.writeHead(404).end();
  res.writeHead(200, { "Content-Type": file.endsWith(".js") ? "text/javascript" : "text/html; charset=utf-8" });
  res.end(await readFile(PUBLIC + file));
}

Both files come from a complete example that runs in our tests. See Live data for the whole app.

#Pattern A: server-side apps

Use this for websites rendered on a server, background jobs, scripts, APIs and framework server code (PHP, Django, Rails, Laravel, Express, ASP.NET, Spring, Next.js route handlers and server components).

How it works:

  1. The server reads the key from an environment variable, such as AGENTDB_API_KEY.
  2. It calls AgentDB, then renders HTML or returns JSON.
  3. The browser receives only the result.

Rules:

  • Store the key in your host's secret settings or a .env file that is not in Git. Commit a .env.example with an empty value instead.
  • Load the key only in server code. In Next.js, keep AgentDB calls in files that import "server-only". Never give the variable a NEXT_PUBLIC_, VITE_, PUBLIC_ or REACT_APP_ prefix.
  • Never log the key, and never return it in an error message.
  • Give each app its own key, so you can stop one app without stopping the rest.

#Least-privilege keys

Give your backend the smallest rights that work. Use an agent key: it belongs to one named agent in one workspace, with one role.

RoleRights of the key
viewerRead records and the schema. Good for a public website or a read-only portal.
editorAlso create, change and delete records. Good for a CRM or a form backend.
adminAlso change the schema and manage agents and webhooks. Rarely needed by an app.

Create a read-only agent for a website backend:

RESTChecked
curl -s -X POST "https://app.agentdb.link/v1/workspaces/$AGENTDB_WORKSPACE/agents" \
  -H "Authorization: Bearer $AGENTDB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "website-backend", "role": "viewer", "expires_in_hours": 2160}'

The answer holds the new key once. Put it in your server's secret settings right away.

Narrow it further with permission rules. A rule can hide an object or a field from an agent, or make it read-only. With owner_field, an agent sees only records whose field names that agent. See Roles and permissions.

Keep account keys for your own tools and scripts. Do not deploy them with an app.

#Pattern B: browser app with its own backend

Use this for single-page apps, mobile apps and desktop apps.

  1. The app signs in your users with your own sign-in (sessions, OAuth, a hosted identity provider).
  2. The app calls your backend: /api/contacts, /api/orders.
  3. Your backend checks the session, then decides what this user may see or change.
  4. Your backend calls AgentDB with its key and returns only the fields the screen needs.

Rules for the backend:

  • Check access on every request. For example, filter a query by the signed-in customer's id on the server. Never trust an id or a filter sent by the browser.
  • Expose actions, not the API. Offer POST /api/notes with fixed fields, not a route that forwards any AgentDB call.
  • Return only what is needed. Use fields in a query to leave out internal fields.
  • Show values as text. Record values come from people and agents. Insert them with textContent or your framework's escaping, never as raw HTML.
  • Protect your own forms. Use CSRF protection and rate limits on your backend, as for any web app.
  • Send files through signed links. Create a signed link on the server with POST /files/{id}/links. It expires, needs no key and can be revoked. Put it in an <img> or a download button.

#Pattern C: read-only dashboards

Some pages only show data and have no backend. AgentDB offers two ways that need no key in the page.

Dashboard data links (dashboards_create). Security boundary:

  • The link shows only the panels you defined: totals, or at most 50 rows of the fields you chose. It never reaches other records or other fields.
  • access: "link": anyone with the link can read it. Treat the link as a password.
  • access: "password": the reader also needs a password of 10 to 200 characters.
  • access: "key": only server-side apps with an AgentDB key can read it. There is no hosted page.
  • New link dashboards expire after 90 days by default (expires_in_days, 1 to 365).
  • allowed_origins refuses browsers on other websites. It is not a secret, so combine it with a password or an expiry.
  • dashboards_rotate_token replaces the link; the old one stops at once. dashboards_revoke ends the dashboard.

Custom dashboards (dashboard_sites_create, experimental). AgentDB hosts your HTML, CSS and JavaScript at its own address. Security boundary:

  • The page reads only the data sources declared in its manifest.json, through fetch("/_data/<source>"). A query source must list its fields; only those fields leave the server.
  • The page cannot call other servers or load outside scripts.
  • Sharing modes: only_me (the owner, with two-step sign-in), link_password, people (invited email addresses) and link. A link site that shows workspace data needs a person's approval in the AgentHub.

See Dashboards and sharing for the details. Both are read-only: to let people change data, use pattern B.

#Rotate and revoke keys

Plan for a key to leak one day. Then the fix takes minutes.

Agent key (recommended for apps):

  1. Create a new agent with the same role and rules.
  2. Put its key in your server's secret settings and redeploy.
  3. Revoke the old agent with agents_revoke or DELETE /v1/workspaces/{ws}/agents/{agent}. Its keys stop at once.

Account key:

  1. Create a new key in Settings → Account API keys.
  2. Deploy it.
  3. Revoke the old key in the same list. It stops working at once.

If a key leaked: revoke it first, then rotate. Then review recent changes: a record's history shows who changed it, and agent actions show as agent:<id>. Also rotate any webhook secret (webhooks_rotate_secret) and dashboard link (dashboards_rotate_token) that were stored next to it.

#Checklist

  • No adbk. key in any file a browser or phone downloads.
  • No NEXT_PUBLIC_, VITE_, PUBLIC_ or REACT_APP_ variable holds a key.
  • The backend uses an agent key with the smallest role.
  • The backend checks the signed-in user on every request.
  • Files reach browsers through signed links that expire.
  • Dashboards use a password or an expiry when the data is not public.
  • Webhook requests are verified with their signature (Live data).
  • You know how to rotate each key in under 10 minutes.

Our docs checks enforce part of this list: they fail when an example file or page puts a key or an Authorization header into browser code.