Hardening baseline
This page is a secure configuration baseline for production deployments of
HaltSQL. It is for platform and security engineers who deploy
haltsql-server or haltsql-proxy and want one list of settings to review.
Each row gives the setting, the recommended value, the reason and the page
with the details.
Most defaults are already the safe choice. The rows marked (not default) need an explicit change.
Database
HaltSQL is one layer of three. A bug in HaltSQL, a policy mistake, or a caller that skips the check is contained only by the database's own controls (defense-in-depth.md).
| Setting | Recommended value | Why | Details |
|---|---|---|---|
| Database role for agent queries | A dedicated role: NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS, SELECT granted only on the policy's tables and columns | The database still enforces the basics if HaltSQL is bypassed or wrong | Layer 1 |
| Role session defaults | default_transaction_read_only = on, statement_timeout (for example 5s), lock_timeout, idle_in_transaction_session_timeout | HaltSQL does not bound how long an allowed query runs | Layer 1 |
Role search_path | Identical to the policy's search_path | Otherwise an unqualified name can resolve to a different table than the one HaltSQL checked | defense-in-depth.md |
| Row-level security | ENABLE and FORCE ROW LEVEL SECURITY on every tenant table, keyed on a setting your application sets per transaction | Tenant isolation holds even if a query escapes HaltSQL | Layer 2 |
| RLS coverage check | Run haltsql rls check against the live database in CI or after migrations | Reports where the role, grants and RLS policies in the database have drifted from the HaltSQL policy | rls-generation.md |
| Dangerous functions | Revoke EXECUTE from PUBLIC on functions you do not need (for example pg_sleep) | Defense in depth for functions a policy might allow by mistake | defense-in-depth.md |
Policies
| Setting | Recommended value | Why | Details |
|---|---|---|---|
mode | enforce (the default). Use shadow only during a rollout. | A policy in shadow mode reports violations but allows the query | policy-reference.md |
tenancy.mode | bound (the default) | The tenant value is a bound parameter supplied from the context, never written by the model | tenancy.md |
statements.allow | [select] (the default) unless writes are required | Read-only agents should not be able to write, whatever the SQL says | policy-reference.md |
require_limit.max | Set it (not default), for example 1000 | Bounds rows per query; every SELECT must end in a constant LIMIT n with n <= max | policy-reference.md |
limits | Keep the defaults (max_bytes 16384, max_depth 64, max_nodes 20000, max_joins 8) or lower | Bounds parsing and analysis cost and query shape | policy-reference.md |
tables.*.columns | Explicit column lists; avoid "*" on tables with sensitive columns | "*" allows every current and future column | schema-and-pii.md |
allow_explain | false (the default) | Plans expose planner estimates (row counts) that the agent does not need; EXPLAIN ANALYZE is always denied | policy-reference.md |
| Policy tests | Run haltsql validate, haltsql lint-policy and haltsql test in CI on every policy change | Catches policies that allow more than intended before they ship | policy-ci.md |
Policy delivery
| Setting | Recommended value | Why | Details |
|---|---|---|---|
| Policy source | Load policies only from signed bundles: policies[].bundle or policies[].bundle_source (not default) | The server verifies the Ed25519 signature before it reads a policy, so a compromised file system or delivery channel cannot change them | policy-bundles.md |
public_keys | Only the current signing key, plus the next one during a rotation | Any listed key may sign | Key management |
| Signing key | Generated with haltsql bundle keygen, stored as a protected CI secret, never committed | Whoever holds it can authorize any policy | policy-ci.md |
bundle_source.oci | A digest-pinned reference (repo@sha256:...) for immutable deployments, or a tag with monotonic metadata.version | The poller refuses rollbacks; a digest pins the exact artifact | config-reference.md |
cache_dir | Set, on a writable volume | The server can restart from the last verified bundle while the source is down; the cache is re-verified on every start | policy-bundles.md |
Note: there is no setting that forbids plain
path:orinline:policies. To enforce "signed bundles only", review the config in change control, and check that every policy inGET /v1/admin/statushasorigin: bundle_source(orbundle_or_filefor local bundles).
haltsql-server: transport and callers
| Setting | Recommended value | Why | Details |
|---|---|---|---|
server.tls | Enabled (not default) whenever traffic leaves the pod or host; min_version: "1.3" if all clients support it | Without it, tokens and SQL cross the network in clear text | Built-in TLS |
server.listen | 127.0.0.1:8080 (the default) when TLS is terminated elsewhere or the agent is in the same pod | Keeps the plaintext listener off the network. The container image sets HALTSQL_LISTEN=0.0.0.0:8080, which overrides the config file. | config-reference.md |
server.tls.client_auth | required with server.auth.mtls identities, for service-to-service callers | Strong workload identity; the context comes from config, not the request | mTLS identities |
| Caller authentication | OIDC or mTLS rather than static tokens when the tenant or user must come from an authenticated identity | With static tokens the request body supplies the whole context | authentication.md |
server.auth.oidc[].context_claims | Map the tenant (and user) fields to claims | The tenant comes from the signed token; the body cannot override it (403 CONTEXT_NOT_ALLOWED) | OIDC |
request_context_fields | [] (the default), or the smallest list that works | Every listed field is supplied by the caller, not the identity | authentication.md |
server.auth.oidc[].required_claims | A scope or audience-specific claim, for example scope: sql.verify | Rejects tokens the IdP issued for other purposes | OIDC |
policy_from_groups | Most privileged groups first; default: null; on Entra ID prefer app roles (claim: roles) | The first match wins; no default means unknown callers are rejected; app roles never overflow | Policies from IdP groups |
| Static tokens | One token per agent, at least 32 random bytes (openssl rand -hex 32), only token_sha256 in config | HaltSQL stores an unsalted SHA-256, which is safe only for high-entropy tokens | config-reference.md |
server.auth.allow_unauthenticated | false (the default); leave HALTSQL_ALLOW_UNAUTHENTICATED unset | Unauthenticated callers can name any loaded policy or use default_policy | config-reference.md |
default_policy | Unset (the default) | Only used for unauthenticated requests and requests that name no policy | config-reference.md |
server.admin | Disabled (the default), or callers limited to mTLS or OIDC identities of operators | The admin report reveals query patterns, and with log_raw_sql some SQL | config-reference.md |
/metrics, /healthz, /readyz | Restrict with a NetworkPolicy, or serve probes on --health-listen and disable Prometheus (telemetry.metrics.prometheus: false) if unused | These endpoints need no authentication; metrics are labelled with policy names and reason codes | http-api.md |
limits.max_request_bytes | 1048576 (the default) or lower | Bounds request size | config-reference.md |
secrets and placeholders | Every credential through ${vault:...}, ${aws-sm:...}, ${gcp-sm:...}, ${file:...} or ${env:...}; never inline | Config files and ConfigMaps hold no secrets | secrets.md |
Audit
| Setting | Recommended value | Why | Details |
|---|---|---|---|
on_error | fail_closed (the default) on at least one sink | No verdict is served without its audit record | Failure policy |
buffer / drop | buffer only where availability matters more than synchronous auditing; drop only next to another fail_closed sink | drop discards records with no gap marker in the chain | config-reference.md |
audit.log_raw_sql | false (the default) | SQL can contain sensitive literals; records always carry sql_sha256 and the fingerprint | config-reference.md |
| Durable sink | s3 with object_lock: {mode: COMPLIANCE, retain_days: N} and a spool_dir on persistent storage (not default), or gcs with a bucket retention policy | WORM storage stops whole-log rewrites; the spool keeps the chain continuous across restarts | Restarts and chain continuity |
| Chain verification | Run haltsql audit verify --expect-head <recorded head> on a schedule and alert on failure | A chain proves nothing unless someone checks it | audit-verify.md |
| Kafka sink | tls enabled, sasl with scram-sha-512, acks: all (the default) | Without tls the sink is plaintext | config-reference.md |
| File sink volume | Mount a writable volume at /var/log/haltsql only if you use the file sink | The rest of the root filesystem stays read-only | deployment.md |
| Alerts | On haltsql_audit_errors_total, haltsql_audit_sink_healthy == 0 and haltsql_reasons_total{code="E_INTERNAL"} | Detects audit loss and internal errors early | http-api.md |
OPA
| Setting | Recommended value | Why | Details |
|---|---|---|---|
opa.on_error | deny (the default) | An unreachable or misconfigured OPA must not allow queries | opa.md |
opa.url | A loopback sidecar, or https with tls.ca_file | Plain http is accepted only for loopback | config-reference.md |
opa.headers | A bearer token from a secret placeholder, with OPA's own authentication enabled | Stops other workloads from changing OPA's answers | opa.md |
OPA can only add denials: a query HaltSQL denies is never sent to OPA, and no OPA answer turns a denial into an allow. There is nothing to configure for this.
haltsql-proxy
| Setting | Recommended value | Why | Details |
|---|---|---|---|
tls | Configured (not default); required: true (the default once tls is set) | Without a tls section the proxy accepts plaintext clients | proxy.md |
auth.method | cert with tls.client_auth: require, or scram with long random passwords | The identity selects the policy and the context | proxy.md |
upstream.tls.mode | verify-full with upstream.tls.ca (not default; the default is disable) | require encrypts without verifying the server | proxy.md |
upstream.user | A least-privilege, read-only role, as in Database | The proxy's credentials are the only ones clients use | proxy.md |
upstream.password | A ${env:...} or ${file:...} placeholder | Never in the file | proxy.md |
limits | Set max_connections, max_message_bytes, idle_timeout_seconds for your load | Bounds what one client can consume | proxy.md |
audit | A sink with on_error: fail_closed (the default) | No statement reaches the database without its audit record | proxy.md |
| Network | Only the proxy can reach the database (security groups, NetworkPolicy, pg_hba.conf) | A client that reaches PostgreSQL directly bypasses the proxy | deploy-aws.md |
Runtime
| Setting | Recommended value | Why | Details |
|---|---|---|---|
| Image | ghcr.io/haltsql/haltsql pinned by digest, signature verified with cosign verify | Ensures you run the artifact the release workflow built | supply-chain.md |
| Base image | The default distroless/static-debian12:nonroot runtime stage | No shell, no libc, no package manager | deployment.md |
| User | Non-root, uid/gid 65532 (the image default) | Limits the impact of a process compromise | Dockerfile |
| Root filesystem | readOnlyRootFilesystem: true / docker run --read-only | Nothing in the image writes to the root filesystem | Dockerfile |
| Container security context | allowPrivilegeEscalation: false, capabilities.drop: [ALL], seccompProfile: RuntimeDefault, runAsNonRoot: true | Set by the Helm chart's defaults | values.yaml |
| Service account token | automountServiceAccountToken: false (the chart default) unless Vault Kubernetes auth needs it | HaltSQL does not call the Kubernetes API | values.yaml |
| NetworkPolicy | networkPolicy.enabled: true (the chart default) with ingressFrom set to the agent pods (an empty list allows any pod in the namespace); egress limited with extraRules to the endpoints you configured | Egress is denied by default because verification needs no network access | values.yaml |
| Sidecar topology | One haltsql-server per agent pod, reached on 127.0.0.1 | Verification traffic never leaves the pod | deployment.md |
| systemd | The shipped unit (DynamicUser, ProtectSystem=strict, NoNewPrivileges, empty CapabilityBoundingSet, and more) | Same isolation outside containers; check with systemd-analyze security | deployment.md |
SDKs and the MCP server
| Setting | Recommended value | Why | Details |
|---|---|---|---|
| Execution path | Only the SDK's guarded-execute helper | It checks sql_sha256, binds bind_params, and treats any error as a denial | deployment-modes.md |
statement_timeout_ms, max_rows (Python SDK) | Set both | HaltSQL does not limit run time | sdk-python.md |
haltsql-mcp row cap and timeout | Keep --row-cap (default 200) and --statement-timeout-ms (default 5000) | Bounds what one tool call can return and how long it runs | mcp.md |
haltsql-mcp HTTP transport | A tokens file, and a TLS proxy in front | The MCP server has no built-in TLS | mcp.md |
Checking a deployment
haltsql config show --config FILEprints the effective configuration (flags,HALTSQL_*environment and file merged) with secrets redacted.GET /v1/admin/statusshows loaded policies, their origin and mode, bundle signer key ids, audit sink health and the OPA settings.haltsql-proxy validate -c FILE --jsonprints the proxy's effective config with secrets redacted.