# Hardening (/docs/auth/hardening)



Authentication and authorization decide *who* and *what*. Hardening covers the rest
of the surface: which requests are even accepted, and how secrets are protected at
rest. This page is the production-posture reference.

## Allowed hosts (DNS-rebinding guard) [#allowed-hosts-dns-rebinding-guard]

Ductor can validate the request `Host` header against an allowlist, which defends
against **DNS-rebinding** attacks (a malicious page resolving your API's hostname
to `127.0.0.1` to reach a locally-bound instance).

```bash
export DUCTOR_API_ALLOWED_HOSTS="api.example.com,api.internal.example.com"
```

* The `Host` header is stripped of any port before matching.
* A request whose host is not on the list is rejected with **HTTP 421 Misdirected
  Request**.
* The default list is loopback-only (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`) —
  convenient for local development.

<Callout title="The default is permissive for local dev — pin it in production">
  The middleware treats an **empty** allowlist as "allow all hosts" (no-op
  passthrough). The default covers only loopback names, which is fine for a
  laptop but means a production deployment must set `api.allowed_hosts` to its real
  external hostnames. Leaving it unset/empty behind a proxy disables the guard.
</Callout>

## Connector credential encryption [#connector-credential-encryption]

Third-party connector credentials are the most sensitive data Ductor stores, and
they are encrypted at rest with authenticated encryption.

* **Cipher.** XChaCha20-Poly1305 (AEAD). Every credential blob is sealed with
  **tenant-bound associated data** — `tenantID | providerKey` — so decryption fails
  loudly on any mismatch. That is what blocks a cross-tenant credential swap.

* **Master key.** Supplied as a base64-encoded **32-byte** key:

  ```bash
  export DUCTOR_CONNECTOR_ENCRYPTION_KEY="$(openssl rand -base64 32)"
  ```

* **Key separation.** Newer envelopes HKDF-derive the working key from the master
  key using a purpose string, so blobs written for different purposes are
  cryptographically separated even under the same master key.

* **Per-tenant KEKs.** Where per-tenant key-encryption-keys are configured, an
  additional envelope format binds each tenant's data to its own key, cached and
  invalidated across pods via a Redis eviction channel. The custody control
  plane behind these KEKs — BYOK modes, rotation, and the staged crypto-shred —
  is documented in [Key custody (BYOK)](/docs/auth/key-custody).

### Key rotation and crypto-shred [#key-rotation-and-crypto-shred]

* **Rotation and disable** of a tenant KEK are local-pod immediate and fan out to
  every pod best-effort (bounded by the cache TTL on a missed broadcast).
* **Crypto-shred (destroy)** is stronger: it overwrites the live wrapped
  ciphertext so the key cannot be restored through any normal database path, and
  its cross-pod broadcast is **fail-closed** — an unconfirmed broadcast is returned
  as an error, so a shred never reports success it cannot guarantee.

<Callout title="Rotate the master key deliberately">
  Losing `DUCTOR_CONNECTOR_ENCRYPTION_KEY` makes every stored credential
  undecryptable. Treat it like any other root secret: inject it from a secret
  manager, never commit it, and plan rotation using the documented key-id /
  rotation-keys mechanism so old ciphertext stays decryptable during cutover.
</Callout>

## Session signing secrets [#session-signing-secrets]

The HS256 secrets that sign SAML browser sessions (and other in-process session
JWTs) must be at least **32 bytes**. Secrets are generated as 32 bytes of
cryptographic randomness and stored AEAD-encrypted at rest; the verifier rejects
anything shorter. SAML session tokens additionally pin the signing method
(`HS256`) at verification time, which blocks algorithm-confusion attacks.

## Agent & MCP tool security [#agent--mcp-tool-security]

When Ductor exposes agent or MCP tools, the authorization for those tools is
derived from **server-owned descriptors** — never from labels or schemas a
caller supplies. Each descriptor carries the tool's input/output **schema
hashes**, its read/write/destructive **class**, risk labels, the **required
scope**, and the **source identity**. A caller cannot talk its way into a
higher-privilege tool by asserting a friendlier label or a different schema.

* **Every invocation echoes `tool_version` and `expected_schema_hash`** from the
  server's manifest, and authorization compares that hash with both the
  immutable grant and the current descriptor.
* **Drift, mismatch, missing, or duplicate** descriptors are **denied** and
  produce a redacted decision receipt — a startup descriptor collision is a
  release blocker, not something to paper over with caller metadata.
* **The default policy exposes only server-classified read, non-mutating
  tools.** Write, destructive, and unknown tools are not callable without an
  explicit policy mode and its mutation safeguards.
* Exposure is scoped by the `agent_tool_exposure:read` / `:write` / `:run` /
  `:admin` scopes (see [Authorization](/docs/auth/authorization#agent-tool-exposure-scopes)).

<Callout title="Never infer tool risk from caller input" type="warn">
  Treat a schema mismatch as drift — refresh the tool session rather than
  retrying with a guessed hash — and never restore availability by accepting
  caller-supplied labels or name-based risk inference. The full model is in
  [AI → Agent tool security](/docs/ai/agent-tool-security).
</Callout>

## Tamper-evident audit trail [#tamper-evident-audit-trail]

Authorization decisions and credential-lifecycle events can be routed to a
**hash-chained** audit sink (configured on the
[Authorization](/docs/auth/authorization#audit-logging) page). Each event is a
link in a chain: its record hash is `SHA-256(seq ‖ prev_hash ‖ payload)`, and
the first event references a fixed `genesis`. A verifier walks the chain in `seq`
order, recomputing each hash and checking that `prev_hash` matches the prior
link — any seq gap, reordering, or payload edit breaks the chain and is
detectable after the fact.

<Callout title="The audit event schema is frozen" type="info">
  Because the payload is hashed into the chain, the `authz.AuditEvent` field set
  is **frozen** — adding or removing a field would change how historical
  payloads serialize and break verification of the existing chain. Audit
  evidence is append-only in both senses: you cannot rewrite a past event, and
  you cannot reshape the record.
</Callout>

## Enterprise security profile [#enterprise-security-profile]

Setting a named profile folds a stricter posture over your static config:

```bash
export DUCTOR_SECURITY_PROFILE=enterprise
```

Under the `enterprise` profile, an **anonymous / no-op authorizer is not
permitted** — a deployment that would otherwise resolve to the anonymous posture
fails to construct a real authorizer and does not start. It is the belt-and-braces
switch that makes "accidentally running without authorization" impossible in
production.

## Locked-down production checklist [#locked-down-production-checklist]

Work top to bottom before exposing an instance:

<Callout title="Production auth & security checklist">
  **Authentication**

  * `DUCTOR_AUTH_ENABLED=true`, and `DUCTOR_AUTH_ALLOW_ANONYMOUS` **unset/false**
    (it is honored only in `development` anyway).
  * A real identity source is wired: OIDC (`api.oidc_issuer` + `api.oidc_audience`)
    and/or DB-backed API keys (`auth.api_key_enabled`) and/or SAML. The legacy
    static `auth.api_keys` setting is not supported for request authentication.

  **Authorization**

  * Plan the cutover to `authz.allow_when_unconfigured=false`.
  * Configure an audit sink (`audit.sink` = `file`/`stderr`/`stdout`) with
    `hash_chain` on; keep `redact_pii` on in production (it defaults on).

  **Tenancy**

  * Confirm callers are tenant-scoped (claims) or send a validated `X-Tenant-ID`.
  * Reserve tenant `admin` for full same-tenant administration. Provision the
    separate deployment-issued `platform-admin` role only for genuine
    cross-tenant control-plane operators.

  **Entitlements**

  * Run `DUCTOR_ENTITLEMENT_ENFORCEMENT=report` first, watch the grace-admit
    events, then move to `strict`.

  **Hardening**

  * Set `DUCTOR_API_ALLOWED_HOSTS` to your real external hostnames.
  * Provide a 32-byte `DUCTOR_CONNECTOR_ENCRYPTION_KEY` from a secret manager.
  * Terminate TLS in front of the API; ensure SAML `acs_base_url` is HTTPS.
  * Consider `DUCTOR_SECURITY_PROFILE=enterprise` to forbid the anonymous posture.
</Callout>

For the condensed operational runbook of these same knobs, see
[Operations → Security & auth](/docs/operations/security).
