>_

Configuration Reference

Open Privacy Suite configuration is driven by environment variables, with an optional configuration file as a base layer for non-secret settings. This page documents every variable, their defaults, and how they interact across development and production modes.


Configuration File

By default every setting is read from an environment variable (the rest of this page). For deployments with many settings, you can keep the non-secret ones in a single TOML file and point the proxy at it with CONFIG_FILE:

CONFIG_FILE=/etc/privacy-proxy/config.toml
# config.toml
version = 1

ENVIRONMENT = "production"
BASE_URL = "https://proxy.example.com"
NODE_URL = "http://eth-node:8545"
ENABLE_TRAVEL_RULE = true
COMPLIANCE_DEFAULT_MODE = "enforce"

Precedence — the environment always wins. For every setting the proxy checks, in order: (1) the environment variable, (2) the value in CONFIG_FILE, (3) the built-in default. A value set in both the environment and the file resolves to the environment's value, so 12-factor / Kubernetes overrides and secret injection keep working unchanged — the file is a base layer, not a replacement.

Keys are the environment-variable names. A file key is exactly the env var it stands in for (DATABASE_URL, BASE_URL, ENABLE_TRAVEL_RULE, …). Values are typed: strings, booleans, and numbers are all accepted (ENABLE_TRAVEL_RULE = true and ENABLE_TRAVEL_RULE = "true" are equivalent). Nested tables and arrays are not supported.

version is required. The file must declare a top-level version (currently 1). The proxy refuses to start on a missing or unsupported version, so a file written for a future schema fails loudly instead of being silently mis-read.

Secrets are not allowed in the config file

Secrets — JWT_SECRET, JWT_REFRESH_SECRET, JWT_SECRET_PREVIOUS, JWT_REFRESH_SECRET_PREVIOUS, ADMIN_API_TOKEN, OPERATOR_API_TOKEN, AZURE_AD_CLIENT_SECRET, DATABASE_URL, AUDIT_DATABASE_URL, AUDIT_ADMIN_DATABASE_URL, EXPLORER_DATABASE_URL, REDIS_URL / REDIS_PASSWORD, RPC_API_KEY, RPC_API_KEY_ENCRYPTION_KEY, OAUTH_FIRST_PARTY_CLIENTS — must live in your environment or secrets manager, never in a plaintext config file. The proxy refuses to start if it finds one of these in the file, naming the offending key. Inject secrets via the environment, which overrides the file anyway.

A fully-commented template lives at config.example.toml in the repository root — copy and trim it:

cp config.example.toml config.toml

Required in Production

These variables must be set when ENVIRONMENT=production. The server will refuse to start without them.

VariableDescription
JWT_SECRETSecret for signing access tokens
JWT_REFRESH_SECRETSecret for signing refresh tokens
VERIFIER_IDYour Privado verifier DID
ADMIN_API_TOKENShared token for admin API authentication (M2M / bootstrap)
NODE_URLTarget Ethereum node URL
BASE_URLPublic URL for auth callbacks

DATABASE_URL is not enforced here — it has a built-in localhost default, so a production deploy without it starts and silently targets localhost:5432. Set it explicitly.


Timezone

The proxy operates in UTC. The backend container image sets TZ=UTC, and the database connection pins the PostgreSQL session timezone to UTC, so timestamp-based checks (token and membership expiry) are correct regardless of the host or database server's local timezone. No action is required for the bundled deployment. If you run the database separately, leaving its timezone at the default (UTC) is recommended; the proxy does not depend on it but consistent UTC avoids confusion when inspecting rows directly.


Core Settings

VariableDefaultDescription
PORT8080Server listen port
NODE_URLhttp://localhost:8545Target Ethereum node URL
DATABASE_URLpostgres://postgres:postgres@localhost:5432/privacy_proxy?sslmode=disablePostgreSQL connection string
ENVIRONMENTdevelopmentSet to production or development
BASE_URLhttp://localhost:8080Public URL for auth callbacks
TRUSTED_INTERNAL_CIDRS(none)Comma-separated CIDRs for internal API access. Appended to the default list (localhost, Docker, RFC1918, Tailscale). Set when the block explorer or monitoring runs outside default networks (e.g., Kubernetes pod CIDRs, cloud VPCs).
REDIS_URL(none)Redis connection URL for distributed state stores (sessions, caches, rate limiting). When not set, in-memory stores are used (single-instance only). Example: redis://:password@redis:6379/0
ENS_RESOLVER_URLhttps://eth.llamarpc.comPublic RPC endpoint used for ENS name resolution when displaying addresses in the UI. Does not need to match NODE_URL.

Connection Pools & Scaling

These tune connection reuse. Defaults suit a single high-throughput node; size them to your node and to Postgres max_connections when running multiple instances.

VariableDefaultDescription
DB_MAX_OPEN_CONNS50Max open Postgres connections per instance. With N instances, keep N × this under the database's max_connections — use a pooler (e.g. PgBouncer) if you exceed it.
DB_MAX_IDLE_CONNS(= DB_MAX_OPEN_CONNS)Idle connections retained in the pool. Defaults to DB_MAX_OPEN_CONNS so connections are reused, not churned, under bursty load.
DB_CONN_MAX_LIFETIME5mMax lifetime of a pooled Postgres connection before it is recycled.
NODE_HTTP_MAX_IDLE_CONNS_PER_HOST256Idle keep-alive connections kept to the upstream node host. Go's default of 2 causes connection churn at high request rates; raise to match expected concurrency.
NODE_HTTP_MAX_IDLE_CONNS512Total idle keep-alive connections across hosts for the upstream node client.
NODE_HTTP_MAX_CONNS_PER_HOST0Cap on total (active + idle) connections to the node host. 0 = unlimited (the node governs throughput).
NODE_HTTP_IDLE_CONN_TIMEOUT90sHow long an idle keep-alive connection to the node is retained before it is closed.

Audit Logging

The access log is a tamper-evident hash chain (an ISO 27001 / SOC 2 detective control). By default it is written synchronously on the request path. For high throughput, enable the async path: the request appends to a durable local buffer and a background sealer writes the chain off the hot path.

VariableDefaultDescription
AUDIT_BUFFER_DIR(none)Filesystem path for the durable audit buffer. When set, access-log writes leave the request hot path — the request appends here (fsync'd, survives restart) and a background sealer drains it into the chain. Use a durable/replicated volume writable by the proxy's non-root runtime user (uid 1000) — see the ownership note below. Empty = synchronous (legacy).
AUDIT_CHECKPOINT_KEY(none)Enables signed truncation-detection checkpoints. A worker signs each chain's head + row count so the integrity verifier can detect deletion of recent rows (which a plain hash-walk cannot). Source this from a secret DISTINCT from the database credential — a signature the DB-writing identity can also forge adds nothing. Empty = disabled.
AUDIT_CHECKPOINT_INTERVAL1mHow often a signed checkpoint is written per chain.

Durable-buffer note: with AUDIT_BUFFER_DIR set, an entry is durable on the local volume the instant the request returns, but only reaches the (replicated, backed-up) database when the sealer next runs. Keep the buffer on a durable/replicated volume and the seal interval short so the un-sealed window stays small.

Volume ownership: the proxy runs as a non-root user (uid 1000), and the buffer open is fail-hard — if the directory isn't writable the backend will not start (with an error naming the uid). A fresh Docker named volume mounted at the image's pre-created buffer path (/var/lib/pp/auditbuf) inherits the right ownership automatically, so no chown is needed. On Kubernetes, set the pod's securityContext.fsGroup: 1000 so the mounted volume is group-writable. For any other path or a bind mount, ensure the directory is writable by uid 1000 (owned by it, or group-writable via a matching fsGroup).

Separate append-only audit database

The access log always lives in its own PostgreSQL database, separate from your main DATABASE_URL. This lets the running proxy append access-log rows but not rewrite or delete history. The main database has no access-log table at all. Two connection strings control it (both are secrets — keep them in the environment / your secrets manager, not the config file):

VariableDefaultDescription
AUDIT_DATABASE_URLderived from DATABASE_URLRuntime connection string for the audit database. Should connect as the RESTRICTED database role — the one with INSERT + SELECT on access_logs but not UPDATE/DELETE. That role is what makes the append-only seal bite.
AUDIT_ADMIN_DATABASE_URLderived from DATABASE_URLAdmin/owner connection string for the same database. The proxy uses it to run the audit-database migrations at startup and to run retention pruning (which the restricted runtime role cannot do).

At startup the proxy migrates the audit database (as the admin DSN), then reads and writes all access-log data there.

Infra provisions the database; the proxy only migrates it. The proxy never runs CREATE DATABASE. You (your infrastructure-as-code — Terraform / RDS / etc.) must provision the audit database and the restricted role before the proxy starts; the proxy then connects and applies the lean audit schema. In the dev stack the Postgres container does this provisioning for you (see scripts/init-audit-db.sh).

Default (derived) DSNs. If you leave AUDIT_DATABASE_URL / AUDIT_ADMIN_DATABASE_URL unset, each is derived from DATABASE_URL by swapping the database name to <name>_audit on the same server, reusing DATABASE_URL's credentials. This is convenient, but the derived DSN connects as the owner, so the append-only seal is not enforced on it. For production, set AUDIT_DATABASE_URL explicitly to the restricted role.

Deployment dependency — the restricted role is what makes this work. The append-only guarantee comes from the database role the runtime DSN connects as, not from the proxy. AUDIT_DATABASE_URL must connect as the restricted role (privacy_proxy_app, which the audit-database migration grants INSERT + SELECT on access_logs, no UPDATE/DELETE). If it connects as the owner instead (including the derived default), the proxy still works but the seal does nothing — the owner can update and delete rows. AUDIT_ADMIN_DATABASE_URL is the owner DSN and is used only for migrations and retention pruning.

AUDIT_ADMIN_DATABASE_URL must own the audit schema, not just hold grants on it. It runs the migrations, and DDL needs ownership — GRANT ALL PRIVILEGES does not confer it. This bites when you follow the path above: the derived DSN's role created the audit tables, so it stays their owner after you switch to the dedicated roles. Transfer them at the same time. REASSIGN OWNED needs the privileges of both roles, so run it as a superuser, or grant the membership for the duration if <derived_role> may administer privacy_proxy_admin (as it can when it is a managed instance's master user):

-- in the audit database. One transaction: a failed transfer must not leave
-- <derived_role> holding privacy_proxy_admin, which is the separation you came
-- here to get.
BEGIN;
GRANT CREATE ON SCHEMA public TO privacy_proxy_admin;
GRANT privacy_proxy_admin TO <derived_role>;        -- not needed as a superuser
REASSIGN OWNED BY <derived_role> TO privacy_proxy_admin;
REVOKE privacy_proxy_admin FROM <derived_role>;     -- not needed as a superuser
COMMIT;

Existing grants survive the transfer, including the privacy_proxy_app append-only allowlist on access_logs. Do the transfer when you switch: the proxy refuses to start, naming the table and the statement to run, only when a migration is actually blocked by the missing ownership — it does not re-check ownership on every boot, so a deployment can start fine today and fail on the next migration that touches the schema.

Resilience. Set AUDIT_BUFFER_DIR (see above) so a transient audit-database outage does not block request handling: entries are written to a durable local buffer and sealed into the audit database by a background worker when it is reachable again.


Authentication

VariableDefaultDescription
PRIVADO_RPC_URLhttps://rpc-mainnet.privado.idPrivado network RPC endpoint (backs the privado:main state resolver)
BILLIONS_RPC_URLhttps://billions-rpc.eu-north-2.gateway.fmRPC endpoint for the Billions identity chain (chainID 45056). Lets users sign in with the Billions app, not just Privado ID.
BILLIONS_STATE_CONTRACT0x3C9acB2205Aa72A05F6D77d708b5Cf85FCa3a896Billions on-chain identity state contract. Defaults to the shared iden3 cross-chain address; override only if Billions moves it.
IPFS_GATEWAYhttps://ipfs-proxy-cache.privado.idIPFS gateway for schemas
JWT_SECRET(auto in dev)Access token signing secret
JWT_REFRESH_SECRET(auto in dev)Refresh token signing secret
JWT_SECRET_PREVIOUS(none)Optional comma-separated previous access-token secrets, accepted for validation only (never signing). Lets you rotate JWT_SECRET without logging every session out: promote the new secret to JWT_SECRET and move the old one here, then drop it after the access-token TTL elapses.
JWT_REFRESH_SECRET_PREVIOUS(none)Optional comma-separated previous refresh-token secrets, accepted for validation only. Same rotation-window purpose as JWT_SECRET_PREVIOUS; drop old entries after the refresh-token TTL elapses.
VERIFIER_ID(required in prod)Privado verifier DID
ADMIN_API_TOKEN(required in prod)Shared token for admin API access via X-Admin-Token header

Never expose ADMIN_API_TOKEN to the frontend

Do not pass ADMIN_API_TOKEN as a Vite build arg or environment variable to the frontend. Vite bakes VITE_* vars into the static JS bundle, making them visible to every browser visitor. For browser-based admin access, use JWT authentication (login grants admin claim via RBAC). For bootstrap, use curl with X-Admin-Token directly against the backend API.


ProofOfHumanity (Billions)

VariableDefaultDescription
BILLIONS_ISSUER_DID(none)Billions issuer DID for PoH verification
REQUIRE_PROOF_OF_HUMANITYfalse (dev) / true (prod)Require PoH credential for authentication

Runtime Tracing

The proxy uses debug_traceCall to validate all addresses touched by transactions and eth_call reads before forwarding them to the node. This provides comprehensive cross-organization isolation on both writes and reads:

  • All internal CALL/DELEGATECALL/STATICCALL targets are validated against RBAC permissions, on both eth_sendTransaction/eth_sendRawTransaction and eth_call
  • Custom Multicall contracts are detected -- any contract batching calls will have all targets validated
  • CREATE/CREATE2 in runtime is blocked -- prevents unauthorized contract deployment
  • Precompiles (0x01--0x09) are always allowed

Node requirements:

  • Upstream node must support debug_traceCall (Geth, Anvil, Erigon, Nethermind)
  • For Geth: the --http.api debug flag is required

Performance impact:

  • Adds approximately 50--200 ms latency per transaction
  • Tiered validation skips tracing for calls to known org-owned addresses (sends only)
  • Caching reduces overhead for repeated patterns (sends only -- the eth_call path is intentionally uncached, see below)

eth_call cross-org isolation:

eth_call is traced on every request, separately from the send-side path. Three differences matter:

  • Uncached. Proxy-pattern contracts (EIP-1967, Diamond, Beacon, transparent upgradeable) can re-target their internal calls by rewriting a storage slot, so a (from,to,data,value) cache yields stale "allow" decisions after a cross-org upgrade. The eth_call path always asks the upstream node.
  • from rebound to the JWT-bound EOA. Sends pin msg.sender via the unlocked key; reads do not. The proxy looks up the caller's linked Ethereum addresses and rejects user-supplied from that doesn't match (rather than silently rebinding -- mismatches are logged as spoof attempts).
  • Trace runs at the same block as the forwarded call. If eth_call supplies a historical block tag or EIP-1898 object as params[1], the trace runs at that same block. Otherwise an attacker could exfil historical cross-org state via a proxy contract that has since been re-targeted.
VariableDefaultDescription
RUNTIME_TRACING_ETH_CALL_ENABLEDtrueRollback knob for the eth_call cross-org tracing path. Default ON. Set to false ONLY as a documented change-management exception (ISO 27001 A.8.32) -- disabling allows same-org wrapper contracts to bubble up foreign-org state via internal calls. See the runtime toggle endpoint below for the emergency rollback path.
ETH_CALL_TRACE_TIMEOUT5sPer-call timeout for the eth_call trace. Distinct from (and shorter than) the 30s send-side trace timeout — a slow upstream times out faster on the read path. Aggregate trace work per JWT is capped by MAX_CONCURRENT_REQUESTS, which is acquired before the trace runs.

Runtime toggle endpoint (emergency rollback): super-admins can flip the knob without a redeploy via POST /api/v1/admin/system/eth-call-tracing with body {"enabled": false, "reason": "..."}. The change is in-memory only — a restart re-arms the env value. The endpoint requires the super-admin token (ADMIN_API_TOKEN); tier-2 admin JWTs receive 403. Every toggle writes a row to rbac_audit_log and (when configured) fires a SIEM event. GET returns the current state and is available to any admin caller so dashboards can prove the control is on.

Important: eth_call tracing scope

The eth_call cross-org trace fires only when the method resolves to eth_call (via ResolveMethodAlias). Chain-specific aliases declared in EXTRA_RPC_NAMESPACES_FILE with "alias": "eth_call" (e.g. an explicit linea_call entry) are traced. Methods that match a wildcard prefix without an explicit alias are NOT traced — and they're also not subject to RBAC contract access checks or response redaction, by the wildcard's design (see Extra RPC Namespaces below).

This is correct: a method the proxy has no alias mapping for has no defined to/from/data shape, so the proxy has nothing to extract for the trace and no way to know whether the method even runs the EVM. Operators who enable a wildcard namespace are accepting that all of that namespace's methods bypass cross-org isolation. If you need cross-org isolation on a chain-specific method, register it explicitly with an alias rather than relying on a wildcard.

Intra-org contract-grant scoping:

By default, runtime tracing enforces the organization as the isolation boundary: an internal call frame into any contract owned by one of the caller's orgs is allowed, even if the caller's groups have no grant for that specific contract. (A direct call to such a contract is still denied at the entry point — this only concerns contracts reached indirectly through another contract's internal calls.)

Set RUNTIME_TRACING_INTRA_ORG_GRANTS_ENABLED=true to tighten this: when on, an internal frame into a same-org contract is allowed only if the caller actually has a contract grant for it (the same rule applied to the directly-called contract). Turn this on when contract-to-contract composition within an org should respect per-contract grants, not just org membership.

  • Default OFF. Org ownership remains the boundary unless you opt in.
  • Cross-org isolation is unaffected by this flag — calls into another org's contracts (and into unregistered addresses) are always denied regardless.
  • Governs both reads (eth_call, debug_traceCall) and writes (eth_sendTransaction, eth_sendRawTransaction, and contract-deploy constructor calls).
  • In-flight deploys are not blocked. Multi-contract or factory deploys (CREATE2/CREATE3) that reference a precomputed sibling address before it is mined still work — pre-registered deployment addresses are allowed for deploy-claim callers, the same as the direct-call access check.
  • Has no effect when runtime tracing is globally unavailable.
VariableDefaultDescription
RUNTIME_TRACING_INTRA_ORG_GRANTS_ENABLEDfalseWhen true, runtime tracing also requires a contract grant for same-org contracts reached through internal call frames, matching the entry-point access check. When false (default), internal frames into same-org contracts are allowed on org ownership alone. Cross-org isolation is enforced either way.

Runtime toggle endpoint: flip this knob without a redeploy via POST /api/v1/admin/system/intra-org-grant-tracing with body {"enabled": true, "reason": "..."}. Same posture as the eth_call toggle above: super-admin token only, in-memory (a restart re-arms the env value), audit-logged with the required reason, and a GET readable by any admin. Note the directions are not symmetric — turning this off widens access within an org, so the audit row matters most on a disable.


Extra RPC Namespaces

To support chain-specific JSON-RPC methods (e.g. Linea, zkSync, Optimism), create a JSON config file and point to it with EXTRA_RPC_NAMESPACES_FILE:

EXTRA_RPC_NAMESPACES_FILE=./rpc-namespaces.json

All three compose files (docker-compose.yml, docker-compose.privacy.yml, and docker-compose.privacy.dev.yml) include the volume mount and env-var passthrough. The backend only loads the file when EXTRA_RPC_NAMESPACES_FILE is set; leave it empty to disable the feature.

The file format is a versioned JSON object. Two schema versions are supported:

  • v1 — explicit method list per namespace; each method needs an alias to a standard Ethereum method (recommended for tightly-controlled deployments).
  • v2 — same as v1, plus an optional wildcard block per namespace that lets any method matching a prefix pass through without enumeration. Wildcard-matched methods are forwarded as-is — no contract access check, no response redaction. Use only when you trust the upstream chain's namespace.

v1 — explicit methods

{
  "version": 1,
  "namespaces": {
    "Linea": [
      {"method": "linea_estimateGas", "alias": "eth_estimateGas"},
      {"method": "linea_getProof", "alias": "eth_getProof"}
    ]
  }
}
FieldDescription
versionSchema version (1 or 2). Required for forward compatibility.
namespacesMap of display label to method list (v1) or namespace object (v2). The key appears as the section heading in the admin UI.

Every method must specify an alias — a standard Ethereum method it inherits access control and response filtering from. This ensures contract-level checks, storage slot tiering, deployment detection, and participant-based response filtering apply consistently. The proxy rejects the config at startup if any method is missing an alias.

v2 — explicit methods + optional wildcard passthrough

In v2 each namespace value can be either an array (same as v1) or an object with explicit and optional wildcard:

{
  "version": 2,
  "namespaces": {
    "Linea": {
      "explicit": [
        {"method": "linea_estimateGas", "alias": "eth_estimateGas"},
        {"method": "linea_getProof", "alias": "eth_getProof"}
      ],
      "wildcard": {
        "prefix": "linea_",
        "deny": ["linea_sendTransaction", "linea_sendRawTransaction", "linea_sign*"]
      }
    },
    "Trace": {
      "explicit": [],
      "wildcard": {"prefix": "trace_"}
    }
  }
}
Wildcard fieldDescription
prefixRequired. Method-name prefix that opts methods into passthrough (e.g. linea_).
denyOptional list of method names or prefix* globs that the proxy rejects even when they match the wildcard prefix. Useful for state-mutating shapes the operator never wants exposed.

What "passthrough" means for wildcard-matched methods. The proxy:

  • forwards the request to the upstream node verbatim;
  • does not apply contract-level access checks (it doesn't know whether the method has an address parameter);
  • does not redact response fields (it doesn't know the response shape);
  • still applies the global block list (debug_*, admin_*, personal_*, miner_*, txpool_*, clique_*, les_* are always rejected);
  • still applies the namespace's deny list (evaluated before the prefix allow);
  • logs the call with matched_via: "wildcard" and the matched prefix in the SIEM audit stream.

Operator responsibility for wildcard mode. When you enable a wildcard, you are stating that you trust the upstream chain's namespace and accept that methods you haven't explicitly mapped don't get the proxy's privacy guarantees. If a future upstream release adds a method under the prefix that returns sensitive data your tenants shouldn't see, that data passes through. To narrow the surface:

  1. Keep the deny list current as the upstream chain's catalog evolves.
  2. Prefer explicit entries (with proper alias) for any method that benefits from contract-access checks or response filtering.
  3. Don't enable a wildcard for namespaces you don't actually run.

Standard Ethereum-method redaction (eth_getLogs filtering, eth_call return-data redaction, etc.) is unaffected — it continues to operate on the alias target for explicit methods and on standard methods directly.

Group allowed_methods interaction

A group's allowed_methods list works the same in both schemas, plus one v2-only addition: an entry of the form <prefix>* is honored only when a wildcard with that exact prefix is registered globally. Groups cannot invent prefixes the operator hasn't enabled.

{
  "allowed_methods": [
    "eth_call",
    "eth_getLogs",
    "linea_estimateGas",
    "linea_*"
  ]
}

In the admin UI, wildcard-enabled namespaces render a single togglable "Allow all <prefix>* methods (passthrough)" entry alongside the explicit method checkboxes. The deny list is shown read-only as context.

When * (the bare wildcard) is used in a group's allowed methods, all explicitly-known methods are included in the expansion; wildcard-matched methods continue to pass at request time via prefix match.

Precedence: explicit always wins over wildcard

When a method could be matched both ways, explicit entries take precedence at every layer.

Alias resolution. A method that appears in any namespace's explicit list keeps its declared alias and goes through the normal access-control + redaction path. The wildcard does not override that. If the same method also matches the namespace's wildcard prefix, the wildcard never sees it — alias resolution stops at the first explicit hit.

Group allowlist. Inside a group's allowed_methods, an exact method name wins over a <prefix>* glob. The proxy first checks for * (admin shortcut) or an exact match; only if neither hits does it fall through to glob entries.

Deny list scope. The wildcard's deny list narrows the wildcard's surface — it does not act as a global block. If an operator explicitly grants a deny-listed method to a group by exact name (either by listing it in a namespace's explicit block, or by adding the literal method name to a group's allowed_methods), that grant succeeds. The deny list only fires when access would otherwise be granted via the wildcard prefix. To block a method system-wide, rely on GlobalBlockedMethods (compile-time) or simply don't grant it to any group.

Worked example with the config below:

{
  "version": 2,
  "namespaces": {
    "Linea": {
      "explicit": [{"method": "linea_estimateGas", "alias": "eth_estimateGas"}],
      "wildcard": {"prefix": "linea_", "deny": ["linea_sendTransaction", "linea_sign*"]}
    }
  }
}

Group with allowed_methods: ["linea_estimateGas", "linea_*"]:

MethodAlias usedHasMethodOutcome
linea_estimateGaseth_estimateGas (explicit)true (exact match)contract checks + redaction via alias
linea_getProofnone (no explicit entry)true (via linea_* → wildcard match)passthrough, no redaction
linea_sendTransactionnonefalse (wildcard match blocked by deny)rejected with "method not allowed"
linea_signFoononefalse (deny linea_sign* glob)rejected
debug_traceTransactionn/aonly if listed in the group's allowed_methods (or *)gated by the method allowlist like any other method; cross-org isolation on results always enforced

A ready-to-use example is provided in rpc-namespaces.example.json — copy and customize it:

cp rpc-namespaces.example.json rpc-namespaces.json

Token TTLs

TokenTTLNotes
Access Token5 minutesShort-lived; use refresh to renew (dev/mockauth builds use 30 min)
Refresh Token7 daysRotated on each refresh
Auth Session10 minutesTime to complete the auth flow
ETH Link Challenge5 minutesSignature challenge expiry

RPC API Keys

Each RBAC group can have an API key for the upstream RPC node. This enables per-group rate limiting and usage tracking on the upstream side.

VariableDefaultDescription
RPC_API_KEY(none)Global fallback API key for the upstream RPC node. Used when no group-specific key is configured.
RPC_API_KEY_HEADERAuthorizationHeader name used to send the upstream RPC API key. Default 'Authorization' sends 'Bearer <key>'; any other value (e.g. 'X-API-Key') sends the key verbatim under that header. Validated against ^[A-Za-z0-9-]+$ at startup.
RPC_API_KEY_ENCRYPTION_KEY(none)Hex-encoded 32-byte key for encrypting API keys at rest. Required in production. Generate with: openssl rand -hex 32. The proxy refuses to start if this is set to an invalid value (bad hex or wrong length) rather than silently storing keys in plaintext — fix or unset it to boot.
MAX_CONCURRENT_REQUESTS50Maximum concurrent in-flight requests per authenticated user.
MAX_CONCURRENT_ANONYMOUS_REQUESTS= MAX_CONCURRENT_REQUESTSShared concurrency cap for anonymous /rpc traffic (requests with no JWT). The per-user cap is keyed by identity and cannot bound anonymous callers, so this bounds the total in-flight work an unauthenticated flood can create. Set 0 to disable.

Per-group keys are configured in the admin dashboard under group access settings. Keys are encrypted at rest in production and masked in API responses (only last 4 characters visible). Each group can also override rpc_api_key_header to switch between Authorization and any single-header alternative such as X-API-Key.

Rotating RPC_API_KEY_ENCRYPTION_KEY orphans every stored key. To rotate, generate a new key and re-encrypt the stored keys with privacy-cli:

privacy-cli reencrypt-rpc-keys --database-url $DATABASE_URL --old-key $OLD_KEY --new-key $NEW_KEY

Add --dry-run to preview the change without writing. Run it before switching RPC_API_KEY_ENCRYPTION_KEY over to the new value.

The client's IP is forwarded to the upstream node via X-Forwarded-For for upstream rate limiting.

Request size and pagination limits

Fixed limits protect the proxy from oversized or unbounded requests. These are not configurable:

  • Request body size: admin, explorer, and /rpc requests are capped at 1 MB. A declared Content-Length above the cap is rejected with 413 Request Entity Too Large; chunked or under-declared bodies are still bounded and fail with a 4xx response when the handler reads past the cap.
  • Admin list pagination: ?limit= on admin list endpoints is capped at 1000; higher values are clamped down. Non-numeric, zero, or negative values fall back to the endpoint default.
  • Explorer pagination: ?limit= on explorer list endpoints is capped at 100; negative limit/offset values are treated as the default rather than passed through to the database.
  • Batch admin operations: contract sync-delete and batch-move accept at most 200 contract IDs per request; a single contract grant accepts at most 100 custom-address event param rules.

Explorer Pseudonyms

Under a pseudonymous disclosure grant the explorer renders addresses as a stable Address-XXXX alias. The alias is non-reversible in all configurations — it reveals nothing about the underlying address. Setting a key additionally makes aliases non-enumerable, so an attacker cannot recompute the alias for a candidate address.

VariableDefaultDescription
EXPLORER_PSEUDONYM_KEY(none)Optional hex-encoded key that makes explorer address pseudonyms non-enumerable. Recommended in production. When unset, aliases are still stable and non-reversible but can be recomputed from a candidate address; when set, they cannot. Changing this value changes every alias, so set it once and keep it stable. Generate with: openssl rand -hex 32. The proxy refuses to start if this is set to an invalid value (bad hex).

Environment Modes

Development (ENVIRONMENT=development)

  • /auth/verify endpoint enabled for manual testing
  • JWT secrets auto-generated if not set
  • REQUIRE_PROOF_OF_HUMANITY defaults to false
  • Mock tokens accepted: mock.{did} or mock.jwz.token.{did}
  • Mock-login users are auto-granted the admin claim (instant admin dashboard access)

Production (ENVIRONMENT=production)

  • /auth/verify endpoint disabled
  • JWT secrets required (server will not start without them)
  • REQUIRE_PROOF_OF_HUMANITY defaults to true
  • VERIFIER_ID required
  • ADMIN_API_TOKEN required (server will not start without it)

Development-only features

Mock tokens and the /auth/verify endpoint are disabled in production mode. Do not rely on them for integration testing against a production deployment.


Docker Configuration

Services

Standalone proxy (docker-compose.yml, make run):

ServicePortPurpose
postgres5432Database
proxy-backend8080API server
proxy-frontend5173Admin UI
anvil8545Local Ethereum node

Full-stack privacy mode adds (docker-compose.privacy.dev.yml, make full-stack-dev):

ServicePortPurpose
redisPer-user rate-limit / session store
chain-indexer50051 (gRPC)Block indexer (BFF-side data feed)
indexer-postgresChain-indexer database
block-explorer-apiBlock-explorer BFF (privacy-mode build)
block-explorer-frontend3001Block-explorer UI
block-explorer-postgresBlock-explorer database

The prod manifest (docker-compose.privacy.yml) pulls the proxy-backend image rather than building from source; see Deployment for the prod topology and trust-zone split.

Image pinning (privacy mode)

Env varDefaultPurpose
INDEXER_VERSION0.3.0 (dev) / latest (prod)Tag pulled from gatewayfm/chain-indexer (dev, dockerhub) or ghcr.io/gateway-fm/chain-indexer (prod). Verify any new pin with docker manifest inspect before changing — the dev default has no v prefix because the dockerhub tag doesn't carry one.
BLOCK_EXPLORER_PATH../block-explorerSibling-clone location for the block-explorer source (dev only — both api and frontend build from this path).
CHAIN_INDEXER_PATH../chain-indexerSibling-clone location for chain-indexer (only used when overriding the published image with a local build via docker-compose.override.yml).

E2E Testing

The docker-compose.e2e.yml file replaces the mock node with Anvil for realistic testing:

make e2e          # Run full suite
make e2e-debug    # Keep services running after tests
make e2e-down     # Stop services

Database

Connection

Uses pgx v5 with the standard database/sql interface.

# Local development
DATABASE_URL=postgres://postgres:postgres@localhost:5432/privacy_proxy?sslmode=disable

# Docker
DATABASE_URL=postgres://postgres:postgres@postgres:5432/privacy_proxy?sslmode=disable

Migrations

Uses Tern v2 with embedded migrations.

# Run migrations
make db-migrate

# Create new migration
make db-new-migration name=add_feature

Migration policy

Migrations are expand-only in production. Never use DROP in UP migrations. If a migration needs undoing, create a new forward migration.


Compliance and Retention

VariableDefaultDescription
ENABLE_TRAVEL_RULEfalseEnable FATF travel-rule compliance checks on value transfers. Fails closed when token pricing is missing.
COMPLIANCE_DEFAULT_MODEenforceCluster-wide default compliance enforcement mode: 'enforce' (block violations) or 'monitor' (allow the transfer but record it as would-have-blocked). Per-org compliance config overrides it. Sanctioned addresses always block regardless of mode.
RETENTION_ACCESS_LOGS2160hHow long access log entries are retained before the periodic cleaner deletes them. Go duration format (e.g., 2160h = 90 days).
MAX_ACCESS_LOG_ROWS0FIFO row cap on access_logs. When set above 0, the retention loop trims the oldest rows so at most this many remain. Complements RETENTION_ACCESS_LOGS for high-volume deployments. The audit hash chain stays verifiable across the cut via the audit_chain_anchor table.
RETENTION_COMPLIANCE_LOGS61320hHow long compliance log entries are retained. Typically longer than access logs for regulatory audit trails. Default is roughly 7 years.
ORG_ADMIN_VIEW_USER_TXSfalseWhen true, org admins see user-to-user transaction rows that are otherwise dropped (both parties private) in the explorer, with the value/amount preserved so it matches the Transfer event they can already read. Counterparty addresses still render as [PRIVATE] — a volume/timing audit view, not real-address disclosure. Every request that reveals such a row is written to rbac_audit_log (actor, endpoint, row count, client IP). Default off = strict privacy (rows dropped, value zeroed).
OAUTH_FIRST_PARTY_CLIENTS(empty)Silent-SSO allowlist for trusted first-party OAuth clients. Comma-separated entries of the form <client_id>:<bcrypt_hash>. The proxy verifies the matching client_secret at /oauth/token via HTTP Basic (RFC 6749 client_secret_basic) or client_secret_post. Empty = no client gets silent SSO; every flow falls back to interactive Privado. Use per-environment opaque IDs (e.g. explorer-prod-${random}) — the literal default 'explorer' is warned-on at startup in production mode. Recipe: id=explorer-prod-$(openssl rand -hex 8); secret=$(openssl rand -hex 32); hash=$(htpasswd -bnBC 12 '' "$secret" | tr -d ':\n'); echo "${id}:${hash}".

Audit Log Integrity

Both access_logs and rbac_audit_log are protected by a SHA-256 hash chain. Every row links to the previous row's hash so an attacker cannot silently rewrite history without invalidating every downstream hash. A scheduled in-process verifier walks the chain at a configurable interval and fires structured alerts on mismatch.

VariableDefaultDescription
AUDIT_INTEGRITY_VERIFY_INTERVAL15mHow often the in-process verifier walks every audit chain. Set to 0 to disable the scheduled worker entirely (manual verification via privacy-cli still works). Go duration format.
AUDIT_TAMPER_WEBHOOK_URL(empty)Optional generic webhook POSTed with a JSON payload when the verifier detects tampering. Used when no SIEM is configured; wire to Slack incoming webhooks, PagerDuty Events API, Discord, or your alert ingestion bus. Subject to the same SSRF guard as SIEM_WEBHOOK_URL (loopback, RFC-1918, link-local, cloud-metadata IPs are rejected). Leave empty when SIEM forwarding is the alert channel — the verifier already emits a SIEM event of type 'audit.chain.tamper_detected' on every violation.

When a violation is detected, the verifier:

  • Logs a structured slog.Error with the chain name, the offending row id, and the mismatch reason.
  • Increments a Prometheus tamper-violation counter (labelled by chain and reason).
  • Emits a SIEM event of type audit.chain.tamper_detected via the existing SIEM_WEBHOOK_URL forwarder (preferred channel — the customer's SIEM already routes to PagerDuty / Slack / Datadog).
  • POSTs a JSON payload to AUDIT_TAMPER_WEBHOOK_URL if configured.

For on-demand verification (auditor spot-check, incident response), use:

privacy-cli audit verify --database-url $DATABASE_URL --chain all

The CLI is read-only; connect with the privacy_proxy_admin role or a dedicated read-only audit role, not the runtime app credential.


Trusted Proxies

For Docker environments, the server trusts X-Forwarded-For from:

  • 172.16.0.0/12 (Docker networks)
  • 127.0.0.1 (localhost)

Adjust the proxy's trusted-proxy configuration if your deployment requires additional trusted ranges.


Example .env

# Required for production
ENVIRONMENT=production
JWT_SECRET=your-secure-secret-here
JWT_REFRESH_SECRET=your-refresh-secret-here
VERIFIER_ID=did:polygonid:polygon:main:your-verifier-did

# Database
DATABASE_URL=postgres://user:pass@host:5432/dbname?sslmode=require

# Ethereum node
NODE_URL=https://eth-mainnet.alchemyapi.io/v2/your-key

# Privado
PRIVADO_RPC_URL=https://rpc-mainnet.privado.id

# ProofOfHumanity (optional)
BILLIONS_ISSUER_DID=did:polygonid:polygon:main:billions-issuer-did
REQUIRE_PROOF_OF_HUMANITY=true

# Admin API
ADMIN_API_TOKEN=your-admin-token-here

# RPC API key encryption (generate with: openssl rand -hex 32)
RPC_API_KEY_ENCRYPTION_KEY=your-64-char-hex-string-here

# Public URL
BASE_URL=https://your-domain.com