Skip to content

Security model

What backd protects, how, and a checklist before exposing it.

This page summarizes how backd protects data in realms with auth: enabled, and what the operator is responsible for. To report a vulnerability, see SECURITY.md.

Who can do what

CallerProves identity withCan
AnonymousnothingWhat access rules allow user == nil
Usersession token (bds_…)What access rules allow that user; manage their own account and sessions
Server-side serviceAPI key (bdk_…) with the data roleEverything in the realm’s data; act as a user with X-Backd-On-Behalf-Of
Management toolingAPI key with the admin roleThe same, plus the admin API: users, roles, invitations, API keys
Administratorsession of a user holding an admin role, for example through backd loginWhat access rules allow that user, plus the admin API
Operatoraccess to CONFIG_DIR and MongoDBEverything, including creating a realm’s first administrator with backd bootstrap

Credentials belong to one realm and are unknown in every other realm. Invalid, expired or revoked credentials are always refused (401), never downgraded to anonymous access.

How it’s protected

  • Passwords are hashed with argon2id (64 MiB, 3 passes, per-password salt). Hashing concurrency is capped per process so floods can’t exhaust memory.
  • Tokens and keys are 256-bit random values. Only their SHA-256 hashes are stored; they are never logged.
  • Sessions expire after idle time and a maximum lifetime, and end on logout, password change, disabling or deletion. Changes to roles and account status apply on the next request.
  • Password reset answers 202 for every address and counts requests per address and per client for all of them, so it reveals nothing about who is registered; its link only shows a form (a scanner can’t use it up), applies the password policy before using the token, ends every session of the user and tells the owner by email (see Password reset).
  • Sign-up in a realm that requires verified addresses answers 202 for a new address and for a registered one alike (the owner gets an account-exists email), after the same password hashing, so neither the answer nor the time reveals who is registered. A realm that issues a session at sign-up can’t hide it, and answers 409 email_taken (see Email verification).
  • Email change needs the session and the current password, applies only when the new address confirms it, tells the old address with a link that undoes it (and then makes the password unusable, since whoever changed the address may know it), ends every session, and never reveals whether an address is taken. Emailed invitations have no token anyone holds: the link’s token is made at send time, only its hash is stored, and it dies with the invitation (see Sessions).
  • Email from functions is limited by caps, not by recipient: per function per hour, per invocation, per message, and per recipient, with refused attempts counted. A function that can send email and allows anonymous callers is logged as a warning at startup, and the docs state plainly that such a function is a spam and phishing relay from the realm’s sender domain (see Sending email from functions).
  • Account deletion only deactivates (reversible, data kept); erasing is an administrator’s irreversible action that leaves a tombstone with no identity, deletes sign-in methods, sessions and email tokens at once and applies the developer’s per-collection policies in a resumable, audited job (see Deleting and erasing users, which also lists what is kept and for how long).
  • Login gives the same answer, after the same work, for every failure, so neither answers nor timing reveal registered emails. Failed logins are throttled per account and per address, without lockout. Password checks for one account run one at a time in each process, so parallel requests can’t slip past the throttle; with several instances, at most one attempt per instance can run while a wait expires.
  • Administration goes through the admin API. The backd user and backd apikey commands are clients of it and need no database credentials, so every change passes the same authentication, role and network checks. Only backd bootstrap writes to the database, and only to create the first administrator of a realm that has none. The CLI keeps its sessions in a file readable only by its owner.
  • An audit trail records who changed users, roles, network restrictions, API keys and invitations, administrators’ logins and refused admin requests, with the client address and request id. It is append-only, holds no secrets, and in the production reference backd’s own database user can’t change it (see Audit trail).
  • Functions run in a separate executor, one Deno process per call, with no environment, no files but their own bundle, no processes or FFI, and time, memory and output limits. They reach data only through backd, with credentials signed by backd, valid for one call and only on the internal listener: as the caller (rules apply; the caller is resolved by backd, and nothing in the request can choose it, see who is calling) or, with admin: true, with full access recorded as the function. Functions can be internal: no HTTP route at all (404 for every caller), reachable only by backd, scheduled runs, administrators and the functions that list them in calls; the call graph is checked at startup (no cycles, at most 4 functions deep), the callee inherits the caller’s user and never its admin access, and a call outside the declared list is refused at run time. Email is delivered by a function you write, never by backd itself: the realm names an internal, async delivery function, and a worker hands it each finished message. backd stores no message and no working token or link, only the SHA-256 hash of each token (redeemed once, atomically); the token is kept out of the function’s logs, and the number of emails per recipient and per client address is limited. The pages that email links open show a form on GET and act only on POST (so scanners that open links can’t use tokens up), answer every bad token the same way, issue no session, send no-store, no-referrer, a strict CSP and frame-ancestors 'none', and keep the query string out of backd’s access log; the production nginx log format does the same (see Email). The executor never sees the signing key, CONFIG_DIR or MongoDB. Their outbound network is a separate control, below.
  • Scheduled functions (cron) have no caller, so they always receive ctx.admin.db — full access, recorded as the function — whether or not they declare admin: true; review a function with a schedule as if it did. Runs are created by workers under a deterministic id per scheduled time, so extra workers never duplicate them (see Scheduled functions).
  • Function secrets are encrypted at rest (AES-256-GCM) under BACKD_SECRETS_KEY, which backd never persists; a function receives only the values it declares, as ctx.secrets, never in its process environment, and the supervisor masks their exact values in captured logs. Managed only by realm administrators, through the same admin API and audit trail as everything else in this list (see Functions → Secrets). Trust model: a realm’s administrators can set and read (by using) that realm’s own secrets, never another realm’s; the operator holding BACKD_SECRETS_KEY can decrypt every secret of every realm on the instance — the same reach they already have through CONFIG_DIR and MongoDB access.
  • Function invocation history (logs) never stores a call’s input or output, only what happened (status, duration, actor, its own console output) — the same secret-masking captured logs already get. Async job results are the one deliberate exception (a job’s input and output are stored, since there’s nowhere else to keep them until read): a job is readable only by the caller who enqueued it or by any API key, the same “unreadable answers 404, not 403” pattern documents already use, so a job’s existence isn’t revealed to a caller who can’t see it; an anonymous request (no credential at all) answers 401 instead, since there’s no identity to even compare (see Async jobs).
  • Idempotency-Key claims are scoped to the function and the caller, never just the function: two different callers can never collide on the same client-chosen key, even if they happen to pick an identical string, because the caller’s own identity (signed-in user, or API key) is part of what makes a claim’s storage key unique. Without this, a coincidental key collision between two callers could let one receive the other’s stored response (see Idempotency).

Server-side functions: egress and network placement

A function runs code you didn’t necessarily write yourself — an npm dependency, a library update pulled in at build time. If a bug or a compromised dependency in it could reach the network freely, it could read from MongoDB directly (bypassing every access rule), steal your cloud provider’s credentials from its metadata endpoint (SSRF), reach backd’s or the executor’s internal endpoints, or send your data anywhere. Three independent layers stop this, each closing a gap the others leave open — a single hostname allowlist isn’t enough, because it checks names, not the addresses they resolve to, and it can’t see raw TCP at all:

LayerWhat it doesWhat it alone can’t stop
1. Per-function allowlistnetwork: in function.yaml becomes that process’s --allow-net (Deno’s own permission system), plus backd’s address; with no list, a function reaches only backdAn allowed name resolving somewhere it shouldn’t (DNS rebinding, misconfiguration); raw TCP to a host that was never in the allowlist by name
2. backd egressAn HTTP forward/CONNECT proxy every function process is routed through, authenticated with a token scoped to that invocation’s own allowed hosts (minted by the executor, so a compromised function can’t widen its own scope). It resolves every target itself — not trusting the name — and refuses loopback, private, link-local (cloud metadata), CGNAT, multicast and unspecified addresses, with backd’s own internal listener as its only exceptionRaw TCP (Deno.connect), which never consults an HTTP proxy at all
3. Network placementThe executor’s container reaches only backd egress and backd’s internal listener at the network level — MongoDB and everything else is simply unroutable from it, even for a raw TCP connection that bypasses layers 1 and 2 entirelyA raw TCP connection to the executor’s own loopback address, which no network placement can prevent (it’s the same container reaching itself)

That last gap — a function reaching the executor’s own /invoke over its own loopback — is closed by authentication, not the network: /invoke requires BACKD_EXECUTOR_TOKEN, a secret a function’s process never has, so reaching the socket gets an unauthenticated request nowhere.

What’s proven, and what’s still your job. Layers 1 and 2 are enforced by backd itself. Layer 3 is proven correct by docker/egress-test/ in the repository — a real, isolated Docker Compose network where a declared host name resolving to MongoDB’s actual address gets no route at all over raw TCP — and wired into the production reference, whose own test proves it from a real function call in that stack’s actual topology. If you assemble your own deployment instead of adapting the reference, you must reproduce it yourself: the executor’s container or network namespace must have no route to MongoDB, backd’s public listener, or anything else private, other than backd egress and backd’s internal listener. Without it, layers 1 and 2 alone leave raw TCP wide open. See Functions → Egress for the full design and how to run the proof yourself.

  • Email changes are guarded, so a stolen session can’t take over an account: the user must give their password and confirm from the new address, the old address is told and can undo the change for 7 days, and sessions end. An administrator’s change does the same, and is audited.
  • API key scopes can narrow a data key to the collections and functions its service needs (read:blog/posts, call:main/contact), so a leaked key exposes only those: see Scopes.
  • Network restrictions can limit the admin API to your networks, per realm and per admin, confine a user’s login and sessions to theirs, and pin an API key to a service’s addresses (see network restrictions).
  • Access rules gate whole documents. Unreadable documents answer 404, so their existence isn’t revealed. Rules can’t dereference a missing user, a check that happens at startup. Read rules are applied inside the database query, so pages and counts never include unreadable documents.
  • Files are private to their documents: every operation follows the document’s rules before any byte moves, the object key never contains a client-supplied name, the type is detected from the content (a family such as image/* doesn’t admit SVG or HTML), downloads are attachments from the storage’s origin or sandboxed, and upload tokens and link keys are secrets that are hashed, compared in constant time, never logged and out of a function’s reach. Links and caches outlive later rule changes. See Security of files.
  • Provider sign-in (Google, Microsoft, Apple) ends at your app with a one-time code bound to a PKCE challenge, never a session in a URL; the state is single use, the ID token’s signature, issuer, audience, expiry and nonce are verified, the ID token a mobile app sends is verified the same way (and against its own app’s audience), a provider’s address links an existing account only when the provider is trusted for it (Google, Apple, never Microsoft), asserts it verified and the backd account’s is verified too, and backd reaches providers only through a client that dials their hosts and public addresses. See Sign in with providers.
  • Second factor (MFA): a password, a provider or a link alone ends in a challenge for a user with an authenticator, and in a session only after its code. The challenge is a random value stored by hash, lives 5 minutes and takes 5 wrong codes; across challenges 10 wrong codes in an hour lock a user’s second factor for 15 minutes, one check at a time per user so parallel guesses can’t slip past the count. A time step is accepted once (no replay of a code seen over a shoulder), a recovery code once, atomically. The TOTP secret is stored encrypted under BACKD_SECRETS_KEY, recovery codes only as keyed hashes. A wrong code after a right password mails the owner, a used recovery code mails them too, and mfa-reset by an administrator ends every session and is audited. Roles can require it; existing sessions of such a user keep working until they expire.
  • Re-authentication (Re-authentication): password and email changes, account deletion, turning the second factor off, unlinking a sign-in method and an administrator’s API-key creation need a token bound to the caller’s own session, given in exchange for the strongest proof the user has (a code, else the password, else a code by email). The token is a random value stored by hash and lasts reauth.ttl.
  • Magic links (Magic links) have the properties of a password reset: the answer to a request is the same for every address, a link only shows a button (a scanner can’t use it up), the link ends in a one-time code bound to the asking app’s PKCE challenge, and the code typed in the app needs the same verifier and five tries. Link and code are made when the email is written and only their hashes are kept; an address with no account is kept sealed until then. A link that verifies an account whose address was never verified takes it over: the password, linked providers, second factor and sessions set before are removed, so a squatter who signed up with someone else’s address doesn’t keep it.
  • Push devices (Devices) are registered only by a user session, never an API key. A device token is stored encrypted under BACKD_SECRETS_KEY, looked up by its hash and never returned or logged; a token moves to the user who registers it, so a second-hand phone doesn’t keep the previous owner’s pushes; a device goes with the session that registered it (also when the session only expires), after stale_after unseen, and on erasure. A web subscription is an address chosen by the client, so its host must be on the realm’s push.web.endpoint_hosts list, over https on the standard port, which keeps a registration from pointing the worker at an internal service.
  • Push sending (Sending): only functions that declare push can send (a callback token carries the capability, the function’s own function.yaml decides the kinds and audiences), or an administrator with the users area through an audited test send. Provider credentials are realm secrets read by the worker’s senders; requests leave through the egress proxy with a token that names only that unit’s hosts, or directly to public addresses, never following redirects. A web subscription’s endpoint is checked against the realm’s hosts (https, the standard port, no credentials) when it is registered and when it is delivered to. Transport errors are scrubbed of the request URL, because a device token or a subscription’s secret path is part of it; tokens and message content are never logged. Per-user limits have no exemption, a delivery goes only to devices whose session is still alive, units have deterministic ids so a batch that runs again duplicates nothing, and a send’s record holds counts, never content.
  • Queries from clients are translated through an allowlist; they never reach MongoDB as raw query documents.
  • CORS only allows the origins you list, without credentials mode. Tokens travel in the Authorization header, which a page of another site can’t make a browser send, so there is no cross-site request forgery surface. A realm that turns on session cookies does have one, and closes it: changes with only a cookie must come from this API’s own origin or a listed one, and credentials mode is allowed only to the listed origins.
  • Logs never contain request bodies, document contents, passwords or credentials.
  • Responses of /_auth and /_admin carry Cache-Control: no-store, so proxies and browsers don’t cache tokens or account data. Document responses vary on Origin, Authorization and Cookie and are private, no-cache (no-store with signed file links), so a cache never serves one caller’s documents to another; a matching If-None-Match answers 304 only after the read rule has run. A realm’s consents are records of what people accepted: append-only, naming the SHA-256 of the exact text, with the IP address and user agent only if compliance.consent_proof lists them, and reduced at erasure (pseudonymized under a realm secret, removed after compliance.consent_retention). The sign-in consent step is a challenge like a second factor’s: 5 minutes, one use, only its hash stored; after a reaccept_by date a refused session can only accept, read its state, sign out or delete the account. Public texts carry no user data. A collection can opt in to public caching of anonymous reads only; authenticated responses are never public, and a document that stops being public can stay in a CDN for up to the declared max_age (see Caching). Every response carries X-Content-Type-Options: nosniff.

Organizations

Organizations separate customers inside one realm. What protects them, and what the review of the feature left as it is:

  • Non-members see nothing. The helpers (inOrg, hasOrgRole, orgClaims) are pushed into the database query, so another organization’s documents aren’t listed, counted or readable by id, and 403 not_org_member is the same for an organization that doesn’t exist. A differential test compares the query with the in-memory rule against MongoDB for every helper.
  • Revocation is immediate. Memberships are read at each request and cached nowhere: leaving, removal, a changed role, a team change and require_mfa apply at the next request.
  • _meta.org is server-owned. It is set at creation after a membership check, PUT and PATCH that name another one answer 400 immutable_field (a batch ignores it), and only a realm administrator moves a document.
  • Privilege escalation is bounded. A role is granted only by someone who holds what it opens (or has manage: roles), members with capabilities the caller lacks can’t be changed, an organization always keeps a manager (409 last_org_manager), and neither organizations nor teams can carry realm roles.
  • Invitations. Tokens are stored as hashes (an email invitation keeps a sealed copy under BACKD_SECRETS_KEY until it ends), preview and accept need a session, the answers don’t say which check failed, uses are taken atomically, and an open link never gives a capability: this is checked again when it is used, so a team that gains manage after the link was made doesn’t turn it into a way to become a manager.
  • Claims are trusted because only realm administrators and functions write them; comparisons on them are false for non-members.
  • Audited: creation, deletion, renames, require_mfa, members and roles, teams, invitations (created, accepted, revoked), transfers and claims, by ids only.

Left as it is, on purpose:

  • Caps are abuse limits, not guarantees. Two simultaneous requests can create one organization or membership more than max_created_per_user or max_memberships_per_user says.
  • An invitation outlives its inviter’s rights. It grants what its creator could grant when it was made; revoke the pending ones (backd orgs invitations) when a manager is demoted.
  • Slugs are not secret. 409 slug_taken tells a signed-in user that a slug is in use.
  • Accepting at the moment of removal. A member removed while accepting a second invitation of the same organization at the same instant can end up a member again; a manager sees it in the members list.

Team access

Team roles, team-scoped documents, hooks and company templates build on organizations. What was reviewed and fixed, and what is left:

  • Team helpers are database filters. inTeam and hasTeamRole are pushed into the query like the organization ones, over the memberships of the request: windows are applied when they are read, roles that need a second factor the session lacks aren’t in them, and a differential test checks the filter against the in-memory rule for members, holders inside and outside the team, closed windows and people with no team.
  • _meta.team is server-owned. A create names the team after a check that the caller belongs to it or holds a role in it; a team the caller isn’t part of, an unknown one and one of another organization answer 403 not_team_member alike; PUT and PATCH can’t change it; administrators move documents with the transfer.
  • Delegation is bounded. Someone who holds a team role that lists grants can give, re-window and revoke exactly those roles, in that team only. Review fixes: what a delegate gives can’t end later than their own authority (a grant with no end or one beyond theirs is 400), and a delegate can’t change the window of their own grant, so a limited cover can’t be turned into a lasting one. Only manage: teams is unbounded.
  • Windows need nothing to run. Grants outside their window are left out when memberships are read, so access starts and ends on the second; expired grants stay visible for 90 days and are then removed by a sweep.
  • Roles that need a second factor don’t count without one, for the helpers, the pushdown and the endpoints, and the error names the role. API keys on behalf of a user have no second factor, so those roles don’t count for them.
  • Hooks carry ids and role names, never content or tokens. Their seq lets handlers drop stale events. Failures are retried by the job mechanism and never undo the change.
  • Company context can’t be used to probe. org on sign-in, sign-up, magic links and reset requests is used only when the address belongs to a member (or is invited by email); otherwise it is ignored with the same answer and the same work (the lookups run either way).
  • Data hooks are bounded and never in the way. Two seconds, 16 KiB, an object; a failure renders without App. Review fix: a hosted page calls its hook only when it has a company context, so a page anyone can open (a wrong token) can’t make the app’s function run.
  • Company templates can’t run anything. The syntax is logic-less with escaped values, sizes are capped, the HTML is sanitized from what a parser understood when a message is made (so a template saved before a rule changed is still safe), and previews and test sends are limited: a test goes to the requester only, 10 an hour per organization, and previews with real data (which call the hook) 30 an hour (a review fix).
  • Audited: team role grants, window changes and revocations, and template saves and deletions, by ids only, never the text.

Left as it is, on purpose:

  • Owners write what you send from your domain. A company can put its own words in an invitation to any address. Limits (per recipient, per client address, pending invitations) bound it, but keep email.customizable to kinds that need it and overrides.permission to people you trust.
  • Images may be remote. The sanitizer keeps https images, so a template can include a tracking pixel.
  • Any member lists who holds a role in a team, with ids (and emails per members_visible_to): it is what a team page needs.
  • Erased users stay in old events. A hook job keeps its input (ids, role names) until job retention ends.
  • Realtime isn’t covered yet: window boundaries and role changes reaching open streams belong to the realtime milestone.

Reference expansion

Reading a document with expand returns documents of other collections in the same answer. What was reviewed, and what is left:

  • Rules apply to every expanded document, with the caller of the request. The target’s read rule is pushed into the query with the same identity (user, memberships and their second factor, configuration), soft-deleted documents are left out, and an API key’s scopes narrow it: a key that can’t read the target collection gets null. Tests compare a missing reference, one the caller can’t read, one of another organization and a target nobody may read: the same value, the same headers.
  • null is the only answer. Missing, unreadable and foreign are indistinguishable by value, by status and by cost headers, and nothing is logged by caller about which it was.
  • Selection can’t reveal more than a read. Rules are evaluated on the whole document in the database and the selected fields are chosen afterwards in memory, so a rule that depends on a field the request didn’t select still decides, and the field stays out.
  • Only declared relations, checked when backd starts, within the realm. A request can’t name a collection, a database or a field of its own: expand entries are names of declared references and, for the second level, of references declared on the target.
  • Cost can’t be multiplied beyond the limits. At most two levels, five entries, 100 ids per array and 2,000 documents per response; past the last one the answer is 400 before any partial data, decided from the request’s own documents and before the queries run. Each path is one query with the target’s unique key.
  • No shared caching. An expanded read is private, no-cache, even for an anonymous reader of a collection that is publicly cached, and it is never 304; the ETag of a document stays its own version.
  • Writes can’t be steered by it. _expanded is server-owned and dropped from every write; a write never expands.

Left as it is, on purpose:

  • The names of a collection’s references are visible in invalid_expand errors (declared: author, tags). They are configuration, not data, and a client that builds the request needs them.
  • Expansion with ctx.admin.db has admin access, as everything on that client does: a function that returns an expanded document returns what it read. Select fields, and use ctx.db unless the function needs more than its caller may see.
  • A path costs a lookup even when the caller can read nothing there. The counters report it the same either way, so a caller can’t tell it from a miss, but the load is real: the limits are what bound it.

Aggregations and full-text search read many documents in one request. What was reviewed, measured and decided:

  • Rules apply before grouping and searching. The read rule is part of the $match that starts every aggregation and of every search, with the caller’s identity: totals, group keys, total_groups, distinct counts and minimums and maximums cover only documents the caller could list, and a filter that only hidden documents match answers a count of 0, not a hint. API key scopes apply as for a list.
  • The pipeline is fixed. Clients choose fields declared in aggregate:, buckets, metrics and a validated IANA zone; no stage, operator, field name or expression reaches the database from a request. where keeps its allowlist and caps; a $search is one string that the database reads as words, phrases and exclusions, never as operators.
  • The stored search language is server-owned. _search_lang can’t be declared, filtered, sorted or grouped by, and never appears in a document.
  • Cost is bounded by a time limit (5 seconds), no disk use, 1,000 groups, 10,000 distinct values, 10 metrics, 3 group fields, one aggregation at a time per caller and collection, search text of 256 bytes and 10 terms, and relevance pages that stop at 1,000 results.
  • Not exposed where it has no use: the admin data route has no _aggregate.

Load tests (300,000 documents, 5 fields indexed or not, one MongoDB on a development machine; the numbers are indicative, run them again for your hardware with BACKD_LOAD_TEST=1 go test ./internal/mongodb -run TestAggregateAndSearchLoad -v):

RequestTime
total count of the whole collection0.06 s
count by an indexed field (50 groups)0.07 s
count, sum and average by an unindexed field (5 groups)0.45 s
by month in a time zone, whole collection (36 groups)0.7 s
by day, whole collection (1,095 groups)0.2 s
three fields × month, 9,000 groups (above the 1,000 limit)0.85 s
by month, where of the last year (indexed)0.3 s
by category, where of the last month (indexed)0.03 s
count_distinct over 50,000 valuesrefused: aggregate_too_expensive
8 whole-collection aggregations at once1.0 s in total
search: a rare word / a common word0.001 s / 0.4 s
search: three common words, relevance1.8 s
search: five / ten common words, relevance4.6 s / 6.6 s
search: twenty common words, relevance15 s

What the numbers decided:

  • Aggregations are cheap on this scale, even over everything and unindexed, so the 5-second limit is a safety net, not something normal dashboards meet. The index warning stays: the cost grows with the collection, and an index on the where fields is what makes a narrow dashboard instant.
  • A search of many common words is the expensive request, because every document containing any of them has to be scored and sorted. Review fixes: the term cap went from 20 to 10, and a search now has the same time limit as an aggregation (BACKD_AGGREGATE_TIME): past it, 422 search_too_expensive, instead of tying up a database connection until the request timeout.
  • Accepted: a worst case like the table’s last rows (every word in nearly every document) still costs seconds of database time up to the limit. Callers are bounded per instance (a limit of one aggregation at a time, the rate of the realm’s other limits); an attacker with many accounts is the general case of any read endpoint and belongs to the edge’s rate limiting.
  • Deferred: encrypted fields (milestone 16) don’t exist yet; when they do, they must be refused in aggregate.group_by, metrics and text indexes, which is a startup check to add with them.

Triggers

Triggers run functions from changes of documents. What was reviewed, with tests, and what is left:

Reviewed and held

  • No route runs a trigger, and nothing a client sends reaches its input. The dispatcher makes the job, from the change; the input is the change’s ids, version and the fields fields: names. The function is internal, so no HTTP route reaches it; a function that calls it with ctx.call has its own caller’s identity, not the trigger’s.
  • _meta.chain can’t be forged. It is stamped from the callback token that only backd signs; _meta in the body of a create, replace, patch or batch is ignored (tested for each route), and an update replaces the whole _meta, so a write no trigger caused carries no chain. Writes backd makes by itself (an erase’s anonymizing, assigning an organization) drop it too: a stale chain would read as a chained event and count against a loop’s caps. A removal can’t be stamped, and that can’t make a loop endless: every cycle has to create or update a document.
  • A when can’t take the dispatcher down. It sees only document, before, changed(), now and appConfig; no user, no data, no function with side effects. The engine’s memory budget stops expressions built to burn memory or time (a nested iteration over huge ranges is an error in milliseconds): tested, because the dispatcher serves every realm of the deployment.
  • by is information. It comes from _meta.updated_by and is a user id only when a user was the writer; an API key, a function, an erase (backd:erase) or an anonymous writer leave it out. Nothing is granted because of it.
  • Payloads are bounded and minimal. Jobs carry ids unless fields: names paths of the schema (checked at startup), over 256 KiB bodies are left out and flagged, and duplicates collapse on a job id made from the change.
  • Overload is visible, and bounded exactly. The backlog ceiling counts the jobs the dispatcher queues at once (a burst can’t run past it before the next count), chained events are capped per trigger and by depth, first-party events are never dropped for being many, and everything not queued is a recorded gap.
  • One dispatcher, fenced. The lease carries an epoch that every write of the position checks, so an instance that was paused and lost the lease can’t move it. A restore of MongoDB invalidates the stored position and records a gap.
  • Operating it needs the right role. triggers status, gaps and resolve need the functions administration area and are audited; rescan runs where MONGO_URI is, reads one collection’s documents and queues jobs marked rescan.

Accepted, on purpose

  • A trigger function runs with admin data access (ctx.admin.db), because the realm’s configuration declared it. It is the realm’s most privileged code: review it like a function with admin: true. backd triggers status lists every trigger with the note.
  • The author of a when or a function is trusted, as the author of any rule. What is bounded is accident, not intent.
  • Bodies in jobs are copies of data. With fields: ["*"] or paths of personal data, the job’s input holds copies kept for the function records’ retention (functions.log_retention, functions.job_retention), and erasing a user doesn’t rewrite them: they expire. Name the fields the function needs, or none and read the document.
  • by of an erased user’s other writes remains their user id on documents they wrote and didn’t own, until those documents change; the id is a pseudonym, listed in the personal data scope report as _meta.updated_by.
  • Any instance with BACKD_TRIGGERS=true can hold the lease. They are trusted equally; use BACKD_TRIGGERS=false where an instance shouldn’t.

Found and fixed in the review: a stale chain left on documents by backd’s own writes (above), and a backlog count reused for five seconds that let a burst run past the ceiling.

Realtime

Realtime streams send changes to clients as they happen. What was reviewed, with tests, and what is left:

Reviewed and held

  • The same rules as a read, on both sides of every change. A subscriber is told created, updated or removed from evaluating their where and the collection’s read rule (and soft deletion) on the document before and after the change; removed goes only to those who could see it before. A change to a document they could never read produces nothing: not an event, not an id.
  • The in-memory matcher is verified against MongoDB. Everything evaluated per change is restricted to the operators storage.Match implements; a subscription outside them is refused when subscribing, a realtime collection whose read rule compiles outside them stops startup, and generated documents, callers and filters (arrays, missing and null fields, negations) give the same list in memory as in MongoDB.
  • A missing pre-change image fails closed. The affected streams are told to reload (preimage_missing, at most once a second per collection); nothing is guessed and no id leaves.
  • Resume tokens can’t be forged, moved or abused. They are signed with a key derived per realm from the callback key, bound to the realm, the caller and a fingerprint of what the caller’s access depends on, and expire; replay is evaluated with current access. A token issued under other access (a role or membership changed since) is not replayed: the stream reloads, so history made while the caller couldn’t see it is never shown.
  • API keys: scopes are checked per collection when subscribing; a key acting as a user follows that user’s rules and its reloads; revoking or expiring it ends its streams.
  • Access changes reach open streams. Sessions, users, keys, memberships, teams, organizations and the app configuration are watched through MongoDB projections that carry only document keys and field names (never values, never logged); a change ends the stream or reloads the access, and only a real difference resets. Access that ends by the clock (a role’s window) is caught by a periodic read.
  • Everything is bounded: subscriptions per stream, streams per caller (anonymous: per address), per instance and opening rate; buffers per stream and in all (the largest reset first); reload reads are spread and run a few at a time.

Found and fixed in the review

  • A replay could show history made while access was different (removed from a team, added back, resumed): tokens now carry an access fingerprint and are not replayed across a change.
  • A change of the app configuration didn’t reach open streams whose rules read it, so documents a flag had just hidden kept flowing until reconnect. The configuration is watched and the streams reload.
  • An organization-wide change made every member’s stream read the database at once: reads are now spread and bounded.
  • Access that ends with the clock had no event: open streams read their access every five minutes.

Accepted, on purpose

  • A stream holds its credential in memory to read its access again; it is never logged and goes with the stream.
  • A rule that reads now is a different filter every time: such a subscription may be reset at each periodic read. It reloads one list.
  • Who can open many streams is bounded per caller, not globally by identity: an attacker with many addresses can use an instance’s stream budget (BACKD_REALTIME_MAX_STREAMS). Rate limiting by address belongs in front of backd, as for any request.
  • An event carries the whole document as a read does, with no field-level redaction, because reads have none: what a rule shows, the stream shows.
  • Keys derive from the callback key. Rotating BACKD_CALLBACK_KEY invalidates every outstanding token (clients reload), as it does callback tokens.

What you are responsible for

  • TLS. backd serves plain HTTP; terminate TLS in front of it.
  • Rate limiting of requests other than failed logins, in the reverse proxy.
  • TRUSTED_PROXIES when behind a proxy, so throttling sees real clients.
  • API keys: store them as secrets, give each service its own, set expiries and rotate them (see the policy), revoke unused ones. Never ship them to browsers or apps.
  • Rules: review the rules: of each collection.yaml. A missing rule denies, but a too-broad one (read: "true") publishes a collection. Unique indexes can reveal that a value exists: creating a document with a value that a hidden document already has answers 409.
  • Files: a private bucket with a key scoped to the realm’s prefix, a prefix per backd instance, short link lifetimes on sensitive fields, BACKD_URL, and small limits where anonymous callers may upload: see Security of files.
  • Organizations: write inOrg or hasOrgRole in every rule of a collection that belongs to organizations (read: user != nil publishes it to all of them), keep create for organizations to people you trust, and give management roles to few people. See the cookbook.
  • Aggregations and search: declare in aggregate: only the fields a dashboard needs, add the indexes the startup warning names, keep where narrow (dates, organization), and prefer precomputed counters for figures loaded on every page. See the best practices.
  • Reference expansion: a reference is only as private as its target’s read rule: write that rule for the document read directly, select the fields a screen needs, and keep ctx.admin.db expansions out of anything that goes back to a caller. See References and expansion.
  • Team access: keep grants short (who may hand out which role), put require_mfa on roles that can decide for others, guard hook handlers against stale events with seq, make data hooks cheap, and keep company templates to the kinds that need them. See the recipes.
  • Second factors and magic links: require a second factor of every role that can administer the realm (mfa.required_roles; backd warns at startup when an admin role isn’t covered), keep BACKD_SECRETS_KEY safe and backed up (without it the enrolled authenticators can’t be checked), and remember that with magic links the mailbox is a way in: protect it like the password. Tell users never to read a code out to anyone.
  • auth: disabled realms must never be reachable from the internet.
  • Browser apps must protect their users’ tokens against cross-site scripting, or keep the session in an HttpOnly cookie that scripts can’t read (the origin check, SameSite and the JSON-only bodies protect it from CSRF).
  • MongoDB: restrict network access, require authentication and TLS, give backd a least-privilege user, and back up the <realm>___system databases like the rest: they hold password and token hashes.
  • If you run functions and didn’t start from the production reference: network placement for the executor — it must reach only backd egress and backd’s internal listener, nothing else.
  • webhook functions verify their own sender. backd enforces only that a webhook function’s invoke rule allows anonymous callers (refusing to start otherwise, since a webhook sender has no session or API key) — it has no way to know what a genuine request from Stripe, GitHub, or any other sender looks like. Checking the sender’s signature (e.g. against ctx.request.headers) is the function’s own job, every time, before trusting the body; skipping it means anyone who finds the URL can trigger the function (see Webhooks).

Checklist before exposing a realm

Go through the hardening checklist before any realm faces the internet. It covers realms and their rules, credentials, the edge in front of backd, MongoDB and operations, on one page.

Edit this page · Last updated 19 Oct 2026