This document is binding for security-relevant decisions in ducknng. It describes the trust model the framework assumes, the threats that follow from framing ducknng as an RPC server rather than a single-client helper, and the invariants the transport, envelope, registry, manifest, and session layers must uphold. Security is treated here the same way protocol, manifest, and type policy are treated elsewhere in docs/: as part of the public contract that implementation must conform to.
ducknng is a multi-client RPC server. The central invariant, inherited from docs/protocol.md, is that different clients may talk to the same service concurrently and one client must not corrupt, observe, or hijack another client’s state. Every security property in this document is a consequence of that invariant applied to a specific layer of the framework. The server must never assume that a single caller, a single transport connection, or a single process owns the whole service. That assumption is the difference between a DuckDB helper and an RPC framework, and it governs how sessions, registries, and manifests must be designed.
The framework does not define its own cryptographic primitives. Channel confidentiality and integrity come from NNG transport options, client identity comes either from the transport layer or from an RPC-level handshake, and authorization is expressed per method through descriptors in the registry. Those three concerns are separate and must remain separate. Collapsing them into one layer is the most common way an RPC framework grows surprise trust relationships.
ducknng must declare an explicit identity model before sessions, registries beyond discovery, and non-trivial methods are shipped. There are several coherent layers this model can use; the implementation must document which layers are active and how they compose rather than letting implicit trust relationships accumulate.
The first shape is transport-level trust. In this model the chosen NNG transport provides the trust boundary, whether that is inproc://, ipc://, tcp://, tls+tcp://, ws://, or wss://, and network authentication is enforced by the transport when the deployment requires it. The RPC layer does not carry identity of its own and instead relies on transport properties such as validated TLS peer identity, process-local inproc:// boundaries, or filesystem boundaries around a Unix socket. This model requires that TLS be fully implemented when advertised, not silently skipped by a shim, and that user-facing docs make clear that transport choice and exposure are deployment decisions rather than framework-imposed policy. It also means the framework must accept ordinary NNG listener and dial URLs through one transport-agnostic API surface rather than multiplying functions by transport scheme.
The second shape is RPC-level authentication. In this model a control method performs a handshake that returns a capability token, and every subsequent envelope carries that token as an explicit field. Descriptors declare which methods require authentication, and the dispatcher refuses protected methods without a valid token. This model requires an envelope field for the token, not ad-hoc encoding inside payloads, and that field must be added while the envelope is still free to evolve rather than retrofitted later.
The third shape is per-pipe identity derived from NNG pipe metadata. In this model the first message observed on a new nng_pipe establishes an identity, either from a validated TLS peer certificate or from a handshake, and that identity is attached to every subsequent message dispatched from that pipe. Sessions and authorization bind to pipe identity. This model requires the compatibility shim to expose pipe identifiers to the dispatcher, and it requires an explicit policy for what happens when a pipe disconnects and reconnects.
The current implementation combines three deliberately narrow mechanisms. First, query sessions use an RPC-level bearer capability: query_open returns an unguessable session-scoped bearer token, the server stores that token beside the session id, and fetch, close, and cancel must present both session_id and session_token. Second, when a request arrives over a TLS transport with a verified peer certificate, the dispatcher attaches a transport-derived caller identity to the request context. The identity currently prefers the first subject alternative name as tls:san:<value> and falls back to the peer certificate common name as tls:cn:<common-name>. A listener configured with TLS authentication mode 2 (NNG_TLS_AUTH_MODE_REQUIRED) requires such a verified identity before dispatching any method; mode 1 (OPTIONAL) records identity when the transport supplies one but still permits unauthenticated callers. Third, a TLS config or running service can carry an exact verified-peer allowlist. When that allowlist is active, only listed identities are admitted; NULL or an empty SQL string clears the allowlist and returns to the default-open policy, while an empty JSON array [] is active deny-all. Fourth, a service can carry an IP/CIDR allowlist using IPv4/IPv6 literals or CIDR blocks. That allowlist is parsed once into binary rules and can be installed at service startup or changed dynamically with ducknng_set_service_ip_allowlist(...). Fifth, a service can carry basic resource limits such as max_open_sessions, NNG max_active_pipes, max_inflight_requests, and max_sessions_per_peer_identity, set dynamically with ducknng_set_service_limits(...). Sixth, a service can install a SQL authorization callback with ducknng_set_service_authorizer(...). The callback reads the uniform request context through ducknng_auth_context() and returns one row with an allow decision plus optional status, reason, principal, claims, and cache hint columns.
This removes bare-identifier session hijacking without introducing an RPC login handshake. It is not a complete user/role system. exec is not registered by default; ducknng_register_exec_method(true) requires verified transport identity for that method, while false does not. That switch controls only the unary method. The always-on query_open method accepts DuckDB SQL, may execute multiple statements, and truthfully advertises mutates_state = true. Withholding exec therefore does not make a service read-only. A read-only deployment must enforce statement policy through ducknng_set_service_authorizer(...), connection/database permissions, or a separately constrained DuckDB execution context.
Listener TLS authentication mode 2 requires verified identity before any method dispatch. The framework does not choose exposure policy for the operator: plaintext and TLS URL schemes are explicit host decisions. TLS identity exists only when peer verification succeeds and the certificate exposes a usable SAN or common name; deployments relying on it must keep those identities unique within the trusted CA set.
NNG transport admission uses NNG’s own pipe primitive: the service registers NNG_PIPE_EV_ADD_PRE, NNG_PIPE_EV_ADD_POST, and NNG_PIPE_EV_REM_POST callbacks with nng_pipe_notify(...), reads verified TLS peer metadata and remote-address metadata (NNG_OPT_REMADDR) from the nng_pipe, records bounded monitor events for ducknng_read_monitor(...), and calls nng_pipe_close(...) before the pipe is added to the socket when the peer identity or remote address is not admitted. The dispatcher also re-checks the current dynamic fast-path policy for each request, so policy changes affect already-open pipes even though those pipes are not retroactively removed by the ADD_PRE callback. SQL authorizers run after a complete frame has reached the request/dispatch boundary, where method name, transport family, remote address, and HTTP metadata are available without blocking NNG’s pipe callback. The HTTP/HTTPS carrier checks the same fast-path and SQL authorizer policy before RPC dispatch and returns HTTP 403 or the callback-provided status for non-admitted peers or remote addresses. For plaintext tcp://, ws://, or http://, there is no cryptographic peer identity to allowlist; IP/CIDR allowlists and SQL callbacks can still narrow access, but deployments should treat them as network/application gates rather than authenticated transport identity.
The session-family identity contract is therefore sealed around the session token plus optional verified transport identity. The session token remains a bearer secret and, when present, the verified peer identity is an additional owner constraint. It must not be logged casually, embedded in public URLs, or sent over a transport whose exposure would let another principal observe it. A future full authentication model may add envelope-level credentials or richer certificate identity such as certificate fingerprints as an additive capability, but such a layer is not required for the current session contract and must not weaken the existing session_token and mTLS owner checks.
TLS must either work or not be advertised. Accepting TLS configuration while silently ignoring it is a security defect rather than a future feature, because a caller who supplies that configuration reasonably believes the channel is encrypted and authenticated. The current implementation applies supplied TLS configuration to tls+tcp:// and wss:// listeners, to HTTPS frame-carrier servers, to the one-shot raw client request path, to the structured one-shot request/RPC/session helper family that rides on that path, to generic socket dialing through ducknng_dial_socket(..., tls_config_id), and to the unary and incremental ducknng_ncurl families for HTTPS client requests, while still rejecting TLS configuration on non-TLS URL schemes. In client mode, auth_mode = 0 is interpreted as the safe default: the server certificate must verify against the configured CA material and hostname. Server/listener mode keeps the existing peer-authentication meaning where auth_mode = 0 does not require client certificates, and auth_mode = 2 is the mTLS-required setting. The authentication mode and default peer allowlist are exposed through ducknng_list_tls_configs(), and service introspection through ducknng_list_servers() exposes execution_model, active_pipes, max_active_pipes, inflight_requests, max_inflight_requests, max_sessions_per_peer_identity, tls_enabled, tls_auth_mode, peer_identity_required, peer_allowlist_active, peer_allowlist_count, ip_allowlist_active, ip_allowlist_count, and sql_authorizer_active. A listener configured with TLS but no client-certificate requirement is explicitly distinct from one configured with mutual TLS.
The TLS configuration model should not be file-only. The runtime accepts certificate authority material, server certificates, and private keys either from filesystem paths or from in-memory PEM content so that embedded clients and generated test fixtures do not need to round-trip through temporary files. The project also provides helper utilities for assembling client and server TLS configuration and for generating self-signed development certificates, in the same general spirit as the nanonext TLS helpers, while keeping the actual NNG/TLS wiring inside the compatibility layer. ducknng_set_tls_peer_allowlist(tls_config_id, identities_json) sets the allowlist copied into future services that use a TLS handle. ducknng_set_service_peer_allowlist(name, identities_json) changes the same policy dynamically for a running service. ducknng_start_server(...) accepts an optional ip_allowlist_json startup argument for every carrier, with http:// and https:// listeners additionally requiring contexts = 1, while ducknng_set_service_ip_allowlist(name, cidrs_json) changes remote-address admission dynamically. ducknng_set_service_limits(name, max_open_sessions) sets the query-session cap, ducknng_set_service_limits(name, max_open_sessions, max_active_pipes) also sets the NNG active-pipe cap, ducknng_set_service_limits(name, max_open_sessions, max_active_pipes, max_inflight_requests) also sets the concurrent request-boundary cap, and ducknng_set_service_limits(name, max_open_sessions, max_active_pipes, max_inflight_requests, max_sessions_per_peer_identity) also caps concurrent sessions per verified peer identity; 0 means unlimited for any cap. ducknng_set_service_authorizer(name, authorizer_sql) installs or clears the flexible SQL callback; ducknng_auth_context() intentionally returns one row only while that callback is being evaluated.
ipc:// listeners on shared filesystems are subject to filesystem access control. The server must create the socket under a path whose directory permissions restrict access to the intended principal, or must apply restrictive permissions to the socket itself after bind. The default /tmp path used in examples is suitable for single-user development and not for multi-user hosts. That distinction must appear in any user-facing example.
The implemented HTTP and HTTPS transport adapter inherits the same trust model rather than defining a second security model. ducknng_start_server(...) on http:// / https://, ducknng_ncurl(...), and ducknng_ncurl_aio(...) are transport-local helpers, not alternate manifest methods and not authorization bypasses. Registry policy, manifest visibility, session ownership, and Arrow-versus-JSON payload rules must remain identical across carriers even if the outer transport adds headers, status codes, or other HTTP-native metadata. Request and response headers_json values, explicit route response content_type values, and stream route content types reject CRLF and all other control characters before touching NNG HTTP header APIs, so caller-provided headers cannot smuggle additional header lines through string values.
Outbound HTTP credential profiles are fail-closed runtime credentials. A profile carries a non-secret id, request scope, one injected authentication header, version/timestamp/expiry metadata, and the secret header value. The sync, table, unary-AIO, and streaming-open ncurl paths share the same resolver: before sending, ducknng checks scheme, exact host, optional exact port, segment-aware path prefix, method, and TLS requirement, rejects the request if the URL or method falls outside scope, and then injects the profile auth header. A caller header that collides with the profile auth header is rejected rather than allowed to override the credential. If a deployment needs the host to own additional headers beside the injected one, that should be explicit profile policy, for example whole-header ownership or a caller-header allowlist. Introspection through ducknng_list_http_profiles() is redacted and exposes only profile id, scope, auth header names, version, timestamps, and expiry. The current DuckDB C API headers vendored here do not expose a stable Secret Manager register/lookup API, so these profiles are ducknng-managed today while keeping the resolver boundary suitable for future Secret Manager or C++ bridge integration.
The envelope is the first line of defense because every byte that reaches a method handler first passes through the decoder. The envelope must be versioned, and the version must be checked. docs/protocol.md calls the version field mandatory, and the implementation must enforce that by reading a version byte from a fixed position and rejecting frames whose version the server does not understand. Silently misparsing a future envelope as a current one is a forward-compatibility hole and a security hazard, because attacker-influenced bytes land in field positions whose semantics differ between versions.
The decoder must bound every length field before using it. Method names, error strings, and payloads each have their own length, and the sum of these lengths plus the fixed header must not exceed the configured receive limit. Arithmetic performed on wire-provided lengths must be carried out in a width that cannot wrap on any supported platform. The same rule applies to ducknng_quack_batch: top-level schema column counts, reader offsets, fixed-width vector byte counts, array child row counts, list child sizes, and cumulative row counts are checked before allocation, casts, pointer arithmetic, or copies, and overlarge source chunks are rejected instead of being sliced through wrapped arithmetic. The compressed-vector decoder additionally caps each chunk at 4,194,304 cumulative materialized values and checks the budget before temporary-vector or list-child allocation, preventing a small sequence/dictionary body from requesting unbounded expansion. Method names are short protocol labels and must be capped at a small, documented maximum so that an attacker cannot force the dispatcher to scan gigabyte-scale name buffers against the registry. Error strings on incoming frames are not trusted input for the handler and should be ignored when the envelope type is a call.
Flags are contract rather than freeform bytes. Each method descriptor declares which flag bits it accepts on requests and which it emits on replies. The dispatcher must reject unknown flag bits on incoming frames rather than ignoring them, because ignore-unknown is the behavior that turns future flag allocations into silent protocol breaks. This rule applies uniformly across methods and does not have per-method exceptions.
The registry is a security boundary, not only an extensibility mechanism. Only methods registered in the registry may be dispatched, and the dispatcher must never contain a hardcoded method name that shortcuts the registry. Each descriptor must carry, in addition to the fields required by docs/manifest.md, the security-relevant properties that the dispatcher needs to enforce before calling a handler. Those properties include whether the method requires authentication, whether it mutates server state, whether it requires an already-open session, whether it opens a new session, the maximum accepted request payload size, the maximum permitted reply payload size, and whether the method is deprecated to the point of being disabled on this deployment.
Enforcement should happen in the dispatcher and session manager, not as scattered per-method folklore. The dispatcher validates the envelope, method lookup, flags, payload limits, and any required verified peer identity before a handler runs; the session manager verifies session-token ownership and mTLS owner-identity binding before returning or removing a session. Future sessionful methods should continue moving toward a request-context path where handlers receive already-resolved authorized session state instead of each inventing a separate ownership check. The manifest exported to clients is derived from the same descriptors, so whatever the manifest says about authentication, session behavior, and payload limits must be enforced by the same runtime path.
Bulk registration and unregistration are part of this story. The registry can remove individual methods or every method in a family, and unregistration is session-aware: it refuses to unregister a sessionful method or family while any service still has open sessions. That conservative policy is safer than stranding client-owned state. A later drain mode may refuse new openings while existing sessions are allowed to complete or are force-closed through a documented lifecycle, but silent removal of live session control methods is not permitted.
Sessions are the framework’s main cross-client trust boundary and must carry an explicit owner. In the current query-family implementation the baseline owner proof is a session-scoped bearer token generated at query_open. The server records that token as session metadata and refuses fetch, close, and cancel when the supplied token does not match. If query_open arrived with a verified mTLS caller identity, the server also records that identity and requires later fetch, close, and cancel requests to arrive with the same identity. A session with no owner token is a defect; a session with an owner identity must reject a matching token presented by a different or unauthenticated peer.
Ownership is modeled as session metadata that lives beside the session id rather than inside it. The session id is only a lookup key. The owner token, optional owner identity, creation timestamp, idle deadline, current lifecycle state, and originating method descriptor are separate fields that the dispatcher or session manager checks on every session-referencing call. This keeps later identity-model changes out of the public session_id format and prevents accidental capability semantics from creeping into the identifier string.
Session identifiers are not capabilities. A session identifier may be observed, logged, or guessed, and the server must not rely on identifier secrecy for access control. Ownership checks must be performed on every session-referencing call using the session token or a future stronger principal. This rule matters even when the server appears to be single-tenant, because the framework is designed for multi-client use and regressing to identifier-only checks under a single-tenant assumption is how multi-tenant deployments later break.
Session lifecycle is part of the security contract. A session has an explicit open, an explicit close, and an idle timeout. The idle timeout counts only owner activity; non-owner references to a session must not reset the idle counter, because otherwise an attacker who can enumerate or guess identifiers can keep sessions alive indefinitely or, in a different implementation, prevent their cleanup. Cancel is best-effort by the nature of the NNG REP pattern, and its exact guarantees must appear both in docs/protocol.md and in the method descriptor so that clients and operators understand what a successful cancel reply does and does not promise.
The required guarantees for the query family are intentionally narrow. A successful cancel reply acknowledges that the server accepted the request to stop further work on that session; it does not guarantee that the underlying DuckDB work was interrupted before producing more rows, nor does it by itself guarantee resource reclamation. Resource reclamation is guaranteed only after close, idle expiry, or an explicit cancel reply that states the session is already closed. A successful fetch after end-of-stream is a protocol misuse and should return a structured error instead of reopening or rewinding the session.
The default implementation serializes service-owned SQL through a shared runtime connection. That contract is surfaced as server.execution.model = "shared_serialized_connection" in the manifest and as execution_model in ducknng_list_servers(). It can be a valid deployment choice when a service is treated as a single DuckDB execution lane. Services that need same-runtime gateway composition or more isolation can switch to service_serialized_connection or request_connection with ducknng_set_service_execution_model(...) before they have active requests or open sessions. Execution-pool connections are opened from the same database handle at extension initialization; they share the same database and catalog, but not temp/session state from the init connection.
SQL-defined methods registered with ducknng_register_sql_method() are the RPC form of that narrow application API. The caller sends a JSON object and never SQL text; the handler SQL is fixed by the host and runs under the same dispatcher admission as every method. Handler SQL still executes with the service connection’s full DuckDB privileges, so a handler that interpolates payload fields into dynamic SQL, COPY, ATTACH, or query() reopens the free-form surface. Handlers should use payload values only as bound data, and they identify the caller through the read-only ducknng_request_subject(), never through fields the payload asserts about itself.
DuckDB is a capable engine, and the capabilities that are innocuous for a local library become dangerous when exposed over an RPC boundary. COPY ... TO and COPY ... FROM provide filesystem write and read under the server’s process identity. ATTACH opens arbitrary database files. INSTALL and LOAD bring in native extension code. The httpfs family, when available, performs outbound network requests. On an unauthenticated or loosely authenticated deployment these capabilities reach well beyond the tabular surface the RPC framework is intended to expose.
This is a deployment boundary, not an automatic ducknng sandbox. ducknng transports and dispatches SQL that a deployment has deliberately exposed through exec or query sessions; it does not rewrite that SQL into a safe subset, and it does not promise to make arbitrary untrusted SQL safe. The extension must instead keep its own SQL construction safe: remote SQL text is executed only as the requested DuckDB statement, not interpolated into hidden administrative SQL; internal SQL that ducknng builds from data values must quote or bind those values; service SQL authorizers are deployment-supplied policy queries and should read request fields through ducknng_auth_context() rather than string-concatenating untrusted HTTP headers, method names, or payload text into new SQL. The JSON body parser is an example of the intended rule: body bytes are copied to text and quoted as a SQL literal before DuckDB JSON functions see them.
Recommended deployment profiles are:
inproc://, loopback, or an ipc:// socket in a private directory; accept default-open SQL only inside that trust boundary.tls+tcp://, wss://, or https:// with mTLS (auth_mode = 2), exact peer allowlists, useful IP/CIDR limits, and service limits. Register unary exec only when clients need it; authorize query_open independently because it already executes SQL.exec or query_open unless the deployment authorizes those clients to run DuckDB SQL as the server process. Install a SQL authorizer and use OS/container/database configuration to restrict filesystem, extension loading, outbound network, and attachment capabilities.ducknng behind a gateway that authenticates, rate-limits, and maps users to a narrow application-level API, or use the HTTP route layer to expose fixed queries or stored operations rather than arbitrary SQL text. When that gateway needs to call another ducknng backend synchronously, prefer a separate DuckDB process or runtime boundary instead of a sibling service in the same shared execution lane.Any filesystem, extension-loading, outbound-network, or attachment lockdown is therefore an operator responsibility expressed through DuckDB configuration, process/container sandboxing, reverse proxies, service meshes, and authorization policy. Future helper profiles may make those settings easier to apply, but the sealed ducknng contract is that dangerous SQL is dangerous by design and must be exposed only inside an appropriate deployment boundary.
Reply size is bounded per method. Unary exec is not the correct place to return arbitrarily large result sets, and its descriptor must declare a maximum reply size that fits a unary reply. Results larger than that bound must be produced through the session family, where clients opt into incremental fetch and the server can apply its own backpressure. This is both a denial-of-service mitigation and an alignment with the protocol’s own stated purpose for the session family.
Error payloads are structured. The envelope carries an error string for human readability, but the normative error signal is a code from the documented status enum. Handlers must not lean on the free-form string to communicate state that clients must act on, because this forces clients to parse English and leaks more about internal schemas and identifiers than the contract intends. Verbose error detail may be made available through a server-side configuration flag for development, but the default must be the narrow structured form.
The manifest is designed to describe the full public capability surface, and that design is correct for clients that are authorized to see that surface. In the default implementation, manifest is registered without descriptor-level requires_auth, but deployments can protect it with the same descriptor policy used for other methods through ducknng_set_method_auth('manifest', true). A listener configured with TLS authentication mode 2 also requires verified peer identity before dispatching every method, including manifest, because that listener policy is stronger than descriptor-local auth. A deployment that requires authentication for discovery must return a consistent and minimal pre-auth response rather than partial method lists that leak which methods exist.
The manifest is derived from the registry and therefore never reveals methods that the registry does not contain. Conversely, the registry must never expose methods that the manifest does not describe. Drift between the two is a security defect because it either hides methods that are dispatchable or advertises methods that are not, and both shapes produce misleading client and operator expectations.
PUB/SUB is reserved by docs/protocol.md for server-originated fanout. Broadcast is an inherent property of the pattern, and PUB/SUB events therefore must not carry per-client-confidential payloads, per-session state, or any data that would be unsafe to deliver to every subscriber. Per-client or per-session notifications require a different pattern, such as SURVEY/RESPONDENT keyed on identity or a session-scoped reply channel, and that choice must be made in docs/protocol.md before the PUB/SUB surface is populated. Event schemas published through PUB/SUB are manifest-declared like any other method output and are subject to the same type-contract discipline described in docs/types.md.
The implemented HTTP/HTTPS carrier does not change that rule. Transport adapters may widen how clients reach the same registry-backed methods, but they do not alter which payloads are fanout-safe, session-bound, or confidential.
The current implementation enforces the baseline limits that are part of the stable service contract: the listener receive-size limit bounds a single request body, method descriptors declare maximum request and reply sizes, query sessions have an idle timeout configured at service start and returned to clients as the effective idle_timeout_ms in query_open replies, running services can enforce a max_open_sessions cap before query_open registers a new session, NNG services can enforce a max_active_pipes cap at pipe ADD_PRE admission, all RPC carriers can enforce a max_inflight_requests cap before SQL authorizers and dispatch, and mTLS deployments can cap open sessions per verified peer identity with max_sessions_per_peer_identity. In the current stable model, the only built-in quota owner identities are the service itself and the verified peer identity; the optional SQL-authorizer principal column is request metadata for deployment policy and audit, not yet durable session ownership metadata. Future additive limits may include concurrent in-flight requests per pipe or principal, principal-based open-session caps, cumulative reply bytes per owner, and session-open rate limits. Per-session idle-timeout hints should only be considered later if bounded by server-side defaults and maxima. Those limits should live in server configuration with conservative defaults and be enforced in the dispatcher and session subsystem, not in individual handlers.
Idle cleanup is a resource bound. The idle timeout belongs to the session, counts only owner activity, and fires without requiring the owner to initiate it. Session close on idle timeout is indistinguishable from explicit close from the client’s perspective, so that an owner cannot use idle-close as a signal channel and a non-owner cannot use idle-close against someone else’s session.
A change to ducknng is considered security-reviewed only when the conditions relevant to that change hold. Already-enforced baseline checks include envelope version validation, bounded wire lengths, descriptor-based request flag validation, method lookup through the registry, per-method request/reply size validation, TLS configuration rejection on incompatible URL schemes, descriptor-level method auth including optional manifest protection, session-aware unregistration refusal while sessions are open, session owner-token checks, optional mTLS owner-identity checks, idle session pruning, and safe internal SQL construction for extension-generated queries. Remaining hardening work includes explicit owner/pipe-level resource quotas, optional convenience helpers for documented deployment profiles, and any future richer pre-authentication discovery policy. PUB/SUB, when introduced, must carry only fanout-safe payloads. This document is updated when any of the above changes, because the checklist is the contract.