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.
| Variable | Description |
|---|---|
JWT_SECRET | Secret for signing access tokens |
JWT_REFRESH_SECRET | Secret for signing refresh tokens |
VERIFIER_ID | Your Privado verifier DID |
ADMIN_API_TOKEN | Shared token for admin API authentication (M2M / bootstrap) |
NODE_URL | Target Ethereum node URL |
BASE_URL | Public 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
| Variable | Default | Description |
|---|---|---|
PORT | 8080 | Server listen port |
NODE_URL | http://localhost:8545 | Target Ethereum node URL |
DATABASE_URL | postgres://postgres:postgres@localhost:5432/privacy_proxy?sslmode=disable | PostgreSQL connection string |
ENVIRONMENT | development | Set to production or development |
BASE_URL | http://localhost:8080 | Public 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_URL | https://eth.llamarpc.com | Public 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.
| Variable | Default | Description |
|---|---|---|
DB_MAX_OPEN_CONNS | 50 | Max 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_LIFETIME | 5m | Max lifetime of a pooled Postgres connection before it is recycled. |
NODE_HTTP_MAX_IDLE_CONNS_PER_HOST | 256 | Idle 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_CONNS | 512 | Total idle keep-alive connections across hosts for the upstream node client. |
NODE_HTTP_MAX_CONNS_PER_HOST | 0 | Cap on total (active + idle) connections to the node host. 0 = unlimited (the node governs throughput). |
NODE_HTTP_IDLE_CONN_TIMEOUT | 90s | How 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.
| Variable | Default | Description |
|---|---|---|
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_INTERVAL | 1m | How 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):
| Variable | Default | Description |
|---|---|---|
AUDIT_DATABASE_URL | derived from DATABASE_URL | Runtime 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_URL | derived from DATABASE_URL | Admin/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
| Variable | Default | Description |
|---|---|---|
PRIVADO_RPC_URL | https://rpc-mainnet.privado.id | Privado network RPC endpoint (backs the privado:main state resolver) |
BILLIONS_RPC_URL | https://billions-rpc.eu-north-2.gateway.fm | RPC endpoint for the Billions identity chain (chainID 45056). Lets users sign in with the Billions app, not just Privado ID. |
BILLIONS_STATE_CONTRACT | 0x3C9acB2205Aa72A05F6D77d708b5Cf85FCa3a896 | Billions on-chain identity state contract. Defaults to the shared iden3 cross-chain address; override only if Billions moves it. |
IPFS_GATEWAY | https://ipfs-proxy-cache.privado.id | IPFS 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)
| Variable | Default | Description |
|---|---|---|
BILLIONS_ISSUER_DID | (none) | Billions issuer DID for PoH verification |
REQUIRE_PROOF_OF_HUMANITY | false (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_sendRawTransactionandeth_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 debugflag 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_callpath 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. fromrebound to the JWT-bound EOA. Sends pinmsg.sendervia the unlocked key; reads do not. The proxy looks up the caller's linked Ethereum addresses and rejects user-suppliedfromthat 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_callsupplies a historical block tag or EIP-1898 object asparams[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.
| Variable | Default | Description |
|---|---|---|
RUNTIME_TRACING_ETH_CALL_ENABLED | true | Rollback 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_TIMEOUT | 5s | Per-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.
| Variable | Default | Description |
|---|---|---|
RUNTIME_TRACING_INTRA_ORG_GRANTS_ENABLED | false | When 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
aliasto a standard Ethereum method (recommended for tightly-controlled deployments). - v2 — same as v1, plus an optional
wildcardblock 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"}
]
}
}
| Field | Description |
|---|---|
version | Schema version (1 or 2). Required for forward compatibility. |
namespaces | Map 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 field | Description |
|---|---|
prefix | Required. Method-name prefix that opts methods into passthrough (e.g. linea_). |
deny | Optional 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
denylist (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:
- Keep the deny list current as the upstream chain's catalog evolves.
- Prefer
explicitentries (with properalias) for any method that benefits from contract-access checks or response filtering. - 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_*"]:
| Method | Alias used | HasMethod | Outcome |
|---|---|---|---|
linea_estimateGas | eth_estimateGas (explicit) | true (exact match) | contract checks + redaction via alias |
linea_getProof | none (no explicit entry) | true (via linea_* → wildcard match) | passthrough, no redaction |
linea_sendTransaction | none | false (wildcard match blocked by deny) | rejected with "method not allowed" |
linea_signFoo | none | false (deny linea_sign* glob) | rejected |
debug_traceTransaction | n/a | only 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
| Token | TTL | Notes |
|---|---|---|
| Access Token | 5 minutes | Short-lived; use refresh to renew (dev/mockauth builds use 30 min) |
| Refresh Token | 7 days | Rotated on each refresh |
| Auth Session | 10 minutes | Time to complete the auth flow |
| ETH Link Challenge | 5 minutes | Signature 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.
| Variable | Default | Description |
|---|---|---|
RPC_API_KEY | (none) | Global fallback API key for the upstream RPC node. Used when no group-specific key is configured. |
RPC_API_KEY_HEADER | Authorization | Header 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_REQUESTS | 50 | Maximum concurrent in-flight requests per authenticated user. |
MAX_CONCURRENT_ANONYMOUS_REQUESTS | = MAX_CONCURRENT_REQUESTS | Shared 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
/rpcrequests are capped at 1 MB. A declaredContent-Lengthabove the cap is rejected with413 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; negativelimit/offsetvalues 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.
| Variable | Default | Description |
|---|---|---|
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/verifyendpoint enabled for manual testing- JWT secrets auto-generated if not set
REQUIRE_PROOF_OF_HUMANITYdefaults tofalse- Mock tokens accepted:
mock.{did}ormock.jwz.token.{did} - Mock-login users are auto-granted the
adminclaim (instant admin dashboard access)
Production (ENVIRONMENT=production)
/auth/verifyendpoint disabled- JWT secrets required (server will not start without them)
REQUIRE_PROOF_OF_HUMANITYdefaults totrueVERIFIER_IDrequiredADMIN_API_TOKENrequired (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):
| Service | Port | Purpose |
|---|---|---|
postgres | 5432 | Database |
proxy-backend | 8080 | API server |
proxy-frontend | 5173 | Admin UI |
anvil | 8545 | Local Ethereum node |
Full-stack privacy mode adds (docker-compose.privacy.dev.yml, make full-stack-dev):
| Service | Port | Purpose |
|---|---|---|
redis | — | Per-user rate-limit / session store |
chain-indexer | 50051 (gRPC) | Block indexer (BFF-side data feed) |
indexer-postgres | — | Chain-indexer database |
block-explorer-api | — | Block-explorer BFF (privacy-mode build) |
block-explorer-frontend | 3001 | Block-explorer UI |
block-explorer-postgres | — | Block-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 var | Default | Purpose |
|---|---|---|
INDEXER_VERSION | 0.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-explorer | Sibling-clone location for the block-explorer source (dev only — both api and frontend build from this path). |
CHAIN_INDEXER_PATH | ../chain-indexer | Sibling-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
| Variable | Default | Description |
|---|---|---|
ENABLE_TRAVEL_RULE | false | Enable FATF travel-rule compliance checks on value transfers. Fails closed when token pricing is missing. |
COMPLIANCE_DEFAULT_MODE | enforce | Cluster-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_LOGS | 2160h | How long access log entries are retained before the periodic cleaner deletes them. Go duration format (e.g., 2160h = 90 days). |
MAX_ACCESS_LOG_ROWS | 0 | FIFO 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_LOGS | 61320h | How long compliance log entries are retained. Typically longer than access logs for regulatory audit trails. Default is roughly 7 years. |
ORG_ADMIN_VIEW_USER_TXS | false | When 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.
| Variable | Default | Description |
|---|---|---|
AUDIT_INTEGRITY_VERIFY_INTERVAL | 15m | How 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.Errorwith 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_detectedvia the existingSIEM_WEBHOOK_URLforwarder (preferred channel — the customer's SIEM already routes to PagerDuty / Slack / Datadog). - POSTs a JSON payload to
AUDIT_TAMPER_WEBHOOK_URLif 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