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. - 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 onOriginandAuthorization, and those to requests with credentials areprivate, no-cache, so a cache never serves one caller’s documents to another (see Caching). Every response carriesX-Content-Type-Options: nosniff.
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. 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.