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
| Caller | Proves identity with | Can |
|---|---|---|
| Anonymous | nothing | What access rules allow user == nil |
| User | session token (bds_…) | What access rules allow that user; manage their own account and sessions |
| Server-side service | API key (bdk_…) with the data role | Everything in the realm’s data; act as a user with X-Backd-On-Behalf-Of |
| Management tooling | API key with the admin role | The same, plus the admin API: users, roles, invitations, API keys |
| Administrator | session of a user holding an admin role, for example through backd login | What access rules allow that user, plus the admin API |
| Operator | access to CONFIG_DIR and MongoDB | Everything, 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
202for 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
202for a new address and for a registered one alike (the owner gets anaccount-existsemail), 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 answers409 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 userandbackd apikeycommands are clients of it and need no database credentials, so every change passes the same authentication, role and network checks. Onlybackd bootstrapwrites 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 bybackd, valid for one call and only on the internal listener: as the caller (rules apply; the caller is resolved bybackd, and nothing in the request can choose it, see who is calling) or, withadmin: true, with full access recorded as the function. Functions can be internal: no HTTP route at all (404for every caller), reachable only bybackd, scheduled runs, administrators and the functions that list them incalls; 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 bybackditself: the realm names an internal, async delivery function, and a worker hands it each finished message.backdstores 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 onGETand act only onPOST(so scanners that open links can’t use tokens up), answer every bad token the same way, issue no session, sendno-store,no-referrer, a strict CSP andframe-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_DIRor 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 declareadmin: true; review a function with ascheduleas 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, whichbackdnever persists; a function receives only the values it declares, asctx.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 holdingBACKD_SECRETS_KEYcan decrypt every secret of every realm on the instance — the same reach they already have throughCONFIG_DIRand 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
401instead, since there’s no identity to even compare (see Async jobs). Idempotency-Keyclaims 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:
| Layer | What it does | What it alone can’t stop |
|---|---|---|
| 1. Per-function allowlist | network: 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 backd | An 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 egress | An 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 exception | Raw TCP (Deno.connect), which never consults an HTTP proxy at all |
| 3. Network placement | The 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 entirely | A 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
stateis 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, andmfa-resetby 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), afterstale_afterunseen, and on erasure. A web subscription is an address chosen by the client, so its host must be on the realm’spush.web.endpoint_hostslist, overhttpson the standard port, which keeps a registration from pointing the worker at an internal service. - Push sending (Sending): only functions that declare
pushcan send (a callback token carries the capability, the function’s ownfunction.yamldecides the kinds and audiences), or an administrator with theusersarea 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
Authorizationheader, 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
/_authand/_admincarryCache-Control: no-store, so proxies and browsers don’t cache tokens or account data. Document responses vary onOrigin,AuthorizationandCookieand areprivate, no-cache(no-storewith signed file links), so a cache never serves one caller’s documents to another; a matchingIf-None-Matchanswers304only 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 ifcompliance.consent_prooflists them, and reduced at erasure (pseudonymized under a realm secret, removed aftercompliance.consent_retention). The sign-in consent step is a challenge like a second factor’s: 5 minutes, one use, only its hash stored; after areaccept_bydate 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 neverpublic, and a document that stops being public can stay in a CDN for up to the declaredmax_age(see Caching). Every response carriesX-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, and403 not_org_memberis 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_mfaapply at the next request. _meta.orgis server-owned. It is set at creation after a membership check,PUTandPATCHthat name another one answer400 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_KEYuntil 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 gainsmanageafter 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_userormax_memberships_per_usersays. - 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_takentells 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.
inTeamandhasTeamRoleare 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.teamis 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 answer403 not_team_memberalike;PUTandPATCHcan’t change it; administrators move documents with the transfer.- Delegation is bounded. Someone who holds a team role that lists
grantscan 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 is400), and a delegate can’t change the window of their own grant, so a limited cover can’t be turned into a lasting one. Onlymanage: teamsis 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
seqlets handlers drop stale events. Failures are retried by the job mechanism and never undo the change. - Company context can’t be used to probe.
orgon 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.customizableto kinds that need it andoverrides.permissionto people you trust. - Images may be remote. The sanitizer keeps
httpsimages, 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
readrule 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 getsnull. 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. nullis 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:
expandentries 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
400before 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 never304; theETagof a document stays its own version. - Writes can’t be steered by it.
_expandedis 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_expanderrors (declared: author, tags). They are configuration, not data, and a client that builds the request needs them. - Expansion with
ctx.admin.dbhas admin access, as everything on that client does: a function that returns an expanded document returns what it read. Select fields, and usectx.dbunless 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 search
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
$matchthat 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.wherekeeps its allowlist and caps; a$searchis one string that the database reads as words, phrases and exclusions, never as operators. - The stored search language is server-owned.
_search_langcan’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):
| Request | Time |
|---|---|
| total count of the whole collection | 0.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 values | refused: aggregate_too_expensive |
| 8 whole-collection aggregations at once | 1.0 s in total |
| search: a rare word / a common word | 0.001 s / 0.4 s |
| search: three common words, relevance | 1.8 s |
| search: five / ten common words, relevance | 4.6 s / 6.6 s |
| search: twenty common words, relevance | 15 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
wherefields 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,metricsand 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 withctx.callhas its own caller’s identity, not the trigger’s. _meta.chaincan’t be forged. It is stamped from the callback token that only backd signs;_metain 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
whencan’t take the dispatcher down. It sees onlydocument,before,changed(),nowandappConfig; nouser, nodata, 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. byis information. It comes from_meta.updated_byand 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,gapsandresolveneed thefunctionsadministration area and are audited;rescanruns whereMONGO_URIis, reads one collection’s documents and queues jobs markedrescan.
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 withadmin: true.backd triggers statuslists every trigger with the note. - The author of a
whenor 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. byof 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=truecan hold the lease. They are trusted equally; useBACKD_TRIGGERS=falsewhere 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,updatedorremovedfrom evaluating theirwhereand the collection’s read rule (and soft deletion) on the document before and after the change;removedgoes 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.Matchimplements; 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
nowis 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_KEYinvalidates every outstanding token (clients reload), as it does callback tokens.
What you are responsible for
- TLS.
backdserves plain HTTP; terminate TLS in front of it. - Rate limiting of requests other than failed logins, in the reverse proxy.
TRUSTED_PROXIESwhen 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 eachcollection.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 answers409. - 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
inOrgorhasOrgRolein every rule of a collection that belongs to organizations (read: user != nilpublishes it to all of them), keepcreatefor 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, keepwherenarrow (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
readrule: write that rule for the document read directly, select the fields a screen needs, and keepctx.admin.dbexpansions out of anything that goes back to a caller. See References and expansion. - Team access: keep
grantsshort (who may hand out which role), putrequire_mfaon roles that can decide for others, guard hook handlers against stale events withseq, 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;backdwarns at startup when an admin role isn’t covered), keepBACKD_SECRETS_KEYsafe 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: disabledrealms 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,
SameSiteand the JSON-only bodies protect it from CSRF). - MongoDB: restrict network access, require authentication and TLS, give
backda least-privilege user, and back up the<realm>___systemdatabases 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 egressandbackd’s internal listener, nothing else. webhookfunctions verify their own sender.backdenforces only that awebhookfunction’sinvokerule 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. againstctx.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.