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

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.
  • 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 08 Oct 2026