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.
// 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):
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):
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;
}/** 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:
- The server reads the key from an environment variable, such as
AGENTDB_API_KEY. - It calls AgentDB, then renders HTML or returns JSON.
- The browser receives only the result.
Rules:
- Store the key in your host's secret settings or a
.envfile that is not in Git. Commit a.env.examplewith 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 aNEXT_PUBLIC_,VITE_,PUBLIC_orREACT_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.
| Role | Rights of the key |
|---|---|
viewer | Read records and the schema. Good for a public website or a read-only portal. |
editor | Also create, change and delete records. Good for a CRM or a form backend. |
admin | Also change the schema and manage agents and webhooks. Rarely needed by an app. |
Create a read-only agent for a website backend:
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.
- The app signs in your users with your own sign-in (sessions, OAuth, a hosted identity provider).
- The app calls your backend:
/api/contacts,/api/orders. - Your backend checks the session, then decides what this user may see or change.
- 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/noteswith fixed fields, not a route that forwards any AgentDB call. - Return only what is needed. Use
fieldsin a query to leave out internal fields. - Show values as text. Record values come from people and agents. Insert them with
textContentor 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_originsrefuses browsers on other websites. It is not a secret, so combine it with a password or an expiry.dashboards_rotate_tokenreplaces the link; the old one stops at once.dashboards_revokeends 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, throughfetch("/_data/<source>"). A query source must list itsfields; 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) andlink. Alinksite 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):
- Create a new agent with the same role and rules.
- Put its key in your server's secret settings and redeploy.
- Revoke the old agent with
agents_revokeorDELETE /v1/workspaces/{ws}/agents/{agent}. Its keys stop at once.
Account key:
- Create a new key in Settings → Account API keys.
- Deploy it.
- 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_orREACT_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.