Security Boundaries in CouchDB Replication

Replication across a fleet of edge nodes, mobile clients, and a centralized cluster is, in practice, a continuous trust negotiation: every stream crosses a security boundary that dictates credential scope, document visibility, and which side is allowed to write a winning revision. This page addresses the exact scenario a distributed-systems team hits when tenant A’s telemetry starts landing in tenant B’s database — a boundary was drawn in the wrong place, and a replication worker faithfully carried data across it. The foundational rules for how revisions move between databases are covered in the parent guide, CouchDB Replication Architecture & Revision Fundamentals; here we operationalize them into enforceable boundaries backed by scoped credentials, per-endpoint _security, deterministic filtering, and automated rotation.

A replication boundary is enforced at three intersecting layers — network transport, database-level access control, and revision authority — and CouchDB evaluates each of them independently on every request. There is no single cross-endpoint handshake that reconciles both databases’ _security up front. Instead, each endpoint authenticates the replication worker using the credentials in the replication document’s source/target auth objects and applies its own _security as the replicator reads from the source and writes to the target.

Three independent security gates in CouchDB replication Data flows left to right: the source database returns only documents the replication user may read, the worker carries only authorized revisions, and the target accepts a write only if the target _security grants a write role. The read gate belongs to the source and the write gate belongs to the target, and each is checked separately. A TLS transport layer spans both hops. There is no combined cross-endpoint handshake — network transport, database access control, and revision authority are each evaluated independently on every request. Every request crosses independent gates — no combined handshake returns only readable docs writes only if authorized Source DB _security 1 · read gate source _security Replication worker carries only authorized revisions 2 · write gate target _security Target DB _security Network transport — TLS on every hop (port 6984) each endpoint authenticated from the replication document's auth objects 1 · Network transport TLS secures every hop 2 · DB access control read gate + write gate 3 · Revision authority target decides the write CouchDB evaluates all three independently on every request.
A replication boundary is enforced at three intersecting layers. Data only crosses when the source's _security permits the read and the target's _security permits the write — there is no single handshake that reconciles both ends.

In edge and IoT deployments, devices frequently operate behind NAT gateways or cellular networks with intermittent connectivity, which makes long-lived interactive sessions impractical. CouchDB accommodates this by authenticating each remote endpoint from the replication document’s own auth objects, while the optional user_ctx sets the local authorization context — and only when a server admin writes the document. If the replication user lacks read access to the source database, the source simply does not return its documents; because CouchDB read access is granted at the database level, per-document exclusion is achieved with filter functions or Mango selectors, not with _security. If the target rejects a write, the replicator records the failure, and a persistent permission error drives the job toward a crashing and eventually failed state. Getting this alignment right is what keeps a mobile offline cache from ever synchronizing a dataset it was never authorized to hold.

Configuration Schema & Required Parameters

The _replicator database is the control plane for every replication job, so it is also where boundaries are declared. A secure, continuous, single-tenant replication document combines scoped auth payloads, a local user_ctx, and exactly one data-partitioning mechanism. Deploy it using the canonical _replicator document schema; the security-relevant fields are highlighted below.

{
  "_id": "rep_edge_to_core_tenant_a",
  "source": {
    "url": "https://edge-node-01.local:6984/iot_tenant_a",
    "auth": { "basic": { "username": "rep_edge_01", "password": "${EDGE_REP_PASSWORD}" } }
  },
  "target": {
    "url": "https://core-cluster.internal:6984/iot_tenant_a",
    "auth": { "basic": { "username": "rep_core_writer", "password": "${CORE_REP_PASSWORD}" } }
  },
  "continuous": true,
  "create_target": false,
  "user_ctx": { "name": "rep_edge_01", "roles": ["iot_tenant_a_sync"] },
  "filter": "tenant_ddoc/tenant_filter"
}

The parameters that define the boundary — and the failure that results from setting them wrong — are summarized here.

Parameter Type Default Security effect
source.auth / target.auth object none Credentials the worker presents to each remote endpoint. Scope these to a per-tenant replication user, never to a server admin.
user_ctx.name / user_ctx.roles object admin context Authorization context for local endpoints only. Honored solely when a server admin writes the document; a non-admin cannot grant roles it does not already hold.
filter string none Design-document filter function (ddoc/name) that excludes documents the boundary must not carry.
selector object none Mango selector — a declarative, index-friendly alternative to a JavaScript filter.
doc_ids array none Explicit allow-list of document IDs to replicate.
create_target bool false Keep false so a job fails fast rather than provisioning a target that lacks a _security object.
continuous bool false Whether the worker holds a persistent _changes listener; affects how long a compromised credential stays live.

Two constraints are non-negotiable. First, filter, selector, and doc_ids are mutually exclusive — CouchDB rejects a document that sets more than one, so choose a single partitioning mechanism per stream. Second, user_ctx has no effect on remote authentication; a common misconfiguration is expecting user_ctx.roles to authorize a write against a remote target, when in fact only the target’s auth credentials and the target database’s _security decide that.

Streaming Detection / Monitoring Setup

Boundary violations announce themselves as authentication and authorization failures on the scheduler, not in the application log. The _scheduler/docs endpoint reports the live state and last error for every replication document, and it is the correct feed to poll for 401/403 transitions. The minimal working poller below surfaces any job whose boundary is being rejected.

import time
import requests

COUCH = "https://core-cluster.internal:6984"
AUTH = ("monitor_ro", "${MONITOR_PASSWORD}")


def poll_boundary_failures(interval: int = 15):
    """Yield replication docs whose state or error signals a boundary rejection."""
    session = requests.Session()
    session.auth = AUTH
    while True:
        resp = session.get(f"{COUCH}/_scheduler/docs", timeout=10)
        resp.raise_for_status()
        for doc in resp.json().get("docs", []):
            state = doc.get("state")
            info = doc.get("info") or {}
            reason = (info.get("error") or "") if isinstance(info, dict) else ""
            if state in {"crashing", "failed"} or "unauthorized" in reason.lower():
                yield doc["doc_id"], state, reason
        time.sleep(interval)


if __name__ == "__main__":
    for doc_id, state, reason in poll_boundary_failures():
        print(f"[boundary] {doc_id}: state={state} reason={reason!r}")

Because a permanent permission denial and a transient network blip both surface as crashing, treat repeated unauthorized/forbidden reasons on the same doc_id as a boundary problem and everything else as a candidate for retry. Wiring these transitions into an alerting path is covered in Async Monitoring & Webhooks; the backoff behavior for the transient cases belongs to Error Handling & Retry Logic.

Core Implementation

Edge environments demand automated credential rotation to contain compromised device keys and stale replication tokens. The orchestrator below rotates a replication user’s credentials without interrupting an active stream: it writes the new secret to _users, patches the _replicator document’s auth payload, and confirms the job returns to running before the old key is retired. It uses structured logging and bounded retries so a rotation event during a mass rollout does not itself become a denial-of-service against the CouchDB cluster.

import logging
import secrets
import time
from dataclasses import dataclass

import requests

log = logging.getLogger("boundary.rotation")


@dataclass
class ReplicationTarget:
    couch_url: str        # e.g. https://core-cluster.internal:6984
    replicator_db: str    # usually "_replicator"
    rep_doc_id: str       # the _replicator document to patch
    rep_username: str     # replication user whose password rotates
    roles: list[str]      # tenant roles the user must retain


class CredentialRotator:
    """Rotate a replication user's password with zero-downtime cutover.

    Steps: generate secret -> update _users -> patch _replicator auth ->
    verify the job is healthy -> only then is the caller safe to discard
    the previous secret.
    """

    def __init__(self, target: ReplicationTarget, admin_auth: tuple[str, str]):
        self.t = target
        self.session = requests.Session()
        self.session.auth = admin_auth  # rotation requires an admin context

    def _request(self, method: str, path: str, **kw):
        url = f"{self.t.couch_url}/{path}"
        for attempt in range(1, 4):  # bounded retry, never unbounded
            try:
                resp = self.session.request(method, url, timeout=10, **kw)
                if resp.status_code < 500:
                    return resp
                log.warning("5xx from %s (attempt %d)", path, attempt)
            except requests.RequestException as exc:
                log.warning("transport error on %s: %s", path, exc)
            time.sleep(2 ** attempt)  # exponential backoff with a hard ceiling
        raise RuntimeError(f"give up after retries: {method} {path}")

    def rotate(self) -> str:
        new_password = secrets.token_urlsafe(32)  # cryptographically strong
        user_id = f"org.couchdb.user:{self.t.rep_username}"

        # 1) Update the user document. CouchDB hashes the password on write.
        user = self._request("GET", f"_users/{user_id}").json()
        user["password"] = new_password
        user["roles"] = self.t.roles  # keep tenant roles intact across rotation
        put = self._request("PUT", f"_users/{user_id}", json=user)
        put.raise_for_status()

        # 2) Patch the active _replicator document's auth payload in place.
        doc_path = f"{self.t.replicator_db}/{self.t.rep_doc_id}"
        rep = self._request("GET", doc_path).json()
        rep.setdefault("source", {}).setdefault("auth", {}).setdefault("basic", {})
        rep["source"]["auth"]["basic"]["password"] = new_password
        self._request("PUT", doc_path, json=rep).raise_for_status()

        # 3) Confirm the job re-authenticated and is healthy before cutover.
        if not self._await_running():
            raise RuntimeError("job did not return to running; keep old secret")
        log.info("rotated credential for %s", self.t.rep_username)
        return new_password

    def _await_running(self, timeout: int = 60) -> bool:
        deadline = time.time() + timeout
        path = f"_scheduler/docs/{self.t.replicator_db}/{self.t.rep_doc_id}"
        while time.time() < deadline:
            state = self._request("GET", path).json().get("state")
            if state == "running":
                return True
            if state == "failed":
                return False
            time.sleep(3)
        return False


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    tgt = ReplicationTarget(
        couch_url="https://core-cluster.internal:6984",
        replicator_db="_replicator",
        rep_doc_id="rep_edge_to_core_tenant_a",
        rep_username="rep_edge_01",
        roles=["iot_tenant_a_sync"],
    )
    CredentialRotator(tgt, admin_auth=("admin", "${ADMIN_PASSWORD}")).rotate()

The cutover ordering matters: patching _replicator before the new secret exists in _users would push the job into crashing, and discarding the old secret before _await_running confirms health would strand the stream. This rotation flow follows the credential-update semantics of HTTP authentication described in RFC 7235, which lets an updated credential take effect on the next request without tearing down the replication session.

Strategy Variants & Trade-offs

There is no single way to draw a boundary; the right choice depends on how strong an isolation guarantee the tenancy model requires and how much operational surface the team can absorb. Four strategies dominate in production, and they compose — filtered replication over a database-per-tenant layout is common.

  • Database-per-tenant isolation — each tenant gets its own database with a dedicated _security object and a dedicated replication user. The strongest boundary, because a read grant simply does not exist across tenants, at the cost of managing many databases and replication documents.
  • Filtered replication — a single shared database uses a filter function, Mango selector, or doc_ids allow-list to exclude documents. Cheaper to operate, but the boundary lives in application-authored logic and must be validated against the partitioning rules before it ships.
  • Proxy-terminated authentication — a reverse proxy or mutual-TLS layer terminates identity before the request reaches CouchDB, presenting a proxied user_ctx downstream. Centralizes credential policy, but adds an out-of-band component whose failure modes are separate from CouchDB’s.
  • Write gating via validate_doc_update — a design-document VDU function rejects writes that violate tenant invariants regardless of how they arrived. A defense-in-depth backstop against a mis-scoped write, but it runs on every write and can become a throughput bottleneck.
Strategy Isolation strength Latency / throughput cost Operational complexity
Database-per-tenant Highest Low High (many DBs + docs)
Filtered replication Medium Low–medium (JS filters slow the feed) Medium
Proxy-terminated auth High Medium (extra hop) Medium–high
validate_doc_update gate Medium (write-side only) Medium–high (runs per write) Low–medium

Note that a JavaScript filter evaluates every change through the query server, whereas a Mango selector can use an index and is generally the faster boundary for high-churn feeds. When two independently authorized streams converge on the same database, the boundary choice also shapes how divergence is later reconciled by revision tree mechanics.

Deployment & Orchestration

Package the rotation orchestrator and boundary poller as a small, stateless service so credentials and endpoints arrive as environment variables rather than baked-in files. The service should read COUCH_URL, ADMIN_PASSWORD, and per-tenant secrets from the platform’s secret store — never from the image — and mount TLS material read-only.

Run exactly one replication worker per source partition. Scheduling two continuous jobs with the same computed replication id against the same source produces duplicated read pressure and racing checkpoints; a single replica per partition keeps checkpoint authority unambiguous. In Kubernetes, model each tenant stream as a Deployment with replicas: 1 (or a StatefulSet when a stable identity simplifies auditing), and gate rollout on a health-check endpoint that returns non-200 whenever any managed job is in failed or has been crashing past a threshold.

from http.server import BaseHTTPRequestHandler, HTTPServer

UNHEALTHY_STATES = {"failed"}


class HealthHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # In production, read cached _scheduler/docs state set by the poller.
        unhealthy = getattr(self.server, "unhealthy_jobs", [])
        code = 503 if unhealthy else 200
        self.send_response(code)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        body = "unhealthy: " + ",".join(unhealthy) if unhealthy else "ok"
        self.wfile.write(body.encode())


if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", 8080), HealthHandler)
    server.unhealthy_jobs = []
    server.serve_forever()

Map each replication stream to a single tenant namespace, enforced by role-based _security objects and explicit user_ctx declarations. When boundaries are aligned with the network partitions described in sync topology models, a rejected write stays contained to one stream instead of triggering retry loops that exhaust cluster resources and mask legitimate failures.

Troubleshooting & Common Errors

Boundary failures cluster around a handful of HTTP status codes and scheduler states. Match the symptom to the remediation before assuming a network fault.

Symptom / code Likely cause Remediation
401 Unauthorized in _scheduler/docs info Wrong or rotated source/target auth credentials Verify the replication user in _users; confirm the rotation cutover completed and patched the auth payload.
403 Forbidden on target writes Replication user lacks a write role in the target _security Add the role to the target database _security.members; do not widen it to _admin.
Job accepted but replicates everything user_ctx.roles mistaken for a remote authorization control Move the boundary to filter/selector/doc_ids; user_ctx governs local endpoints only.
400 Bad Request creating the doc filter, selector, and doc_ids set together Keep exactly one partitioning mechanism per document.
Documents silently missing at target Read grant absent, or a filter excluded them Confirm the user’s read access to the source DB; validate the filter/selector against the partition rules.
crashing immediately after rotation _replicator patched before _users had the new secret Rotate in order: _users first, then _replicator; verify with _scheduler/docs.
user_ctx appears ignored Document written by a non-admin Re-write the _replicator document as a server admin so user_ctx is honored.

When a boundary permanently blocks a write, the scheduler records the cause in the job’s error/reason fields. Parse those to separate transient network errors from permanent permission denials, and route the denials to manual review sync queues for policy reconciliation rather than letting them retry indefinitely.

FAQ

Does CouchDB negotiate a single combined security context across both endpoints?

No. Each endpoint authenticates the worker independently from the replication document’s auth objects and enforces its own _security on every request. There is no up-front handshake that reconciles the source’s and target’s _security; the read gate and the write gate are evaluated separately as the replicator streams changes.

Can I hide individual documents from a replication user with `_security`?

Not directly. CouchDB read access is granted at the database level, so _security controls whether a user can read the database at all, not which documents. To exclude specific documents from a stream, use a filter function, a Mango selector, or an explicit doc_ids allow-list.

Why is my `user_ctx` being ignored?

user_ctx is honored only when a server admin writes the _replicator document, and it applies to local endpoints only. A non-admin cannot assign roles it does not hold, and user_ctx never governs authentication against a remote source/target — that is the job of the auth objects.

How do I rotate replication credentials without stalling an active stream?

Update _users first so the new secret exists, then patch the _replicator document’s auth payload, then confirm the job returns to running via _scheduler/docs before discarding the old secret. Reversing that order pushes the job into crashing.

What happens to conflicts when a boundary blocks a write?

The document is simply never transferred, so the target stays unaware of that revision while the source retains it in its local revision history. How those divergent branches are later tracked and reconciled is governed by the parent pillar’s conflict generation models.

Part of: CouchDB Replication Architecture & Revision Fundamentals