# Action Authentication & Credentials (/docs/connectors/authentication)



Connector authentication has two halves: **what** kind of credential a provider
needs, and **how** that credential is stored and turned into a usable token at
dispatch time. This page covers both, plus the encryption that protects every
stored secret.

## Auth types [#auth-types]

A provider declares the auth types it supports in `Provider.AuthTypes`; a
connection is created with exactly one. The `AuthType` enum
(`domain/connector/auth.go`) has ten values:

| Auth type                 | Value                       | What it is                                                                                                                                             |
| ------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| None                      | `none`                      | No credential — used for public or in-process providers.                                                                                               |
| Secret text               | `secret_text`               | A single opaque secret (or a small set of fields defined by the provider's `CredentialSchema`).                                                        |
| Basic                     | `basic`                     | HTTP basic auth (username + password).                                                                                                                 |
| OAuth2                    | `oauth2`                    | Authorization-code OAuth2 with refresh; tokens managed by Ductor.                                                                                      |
| OAuth2 client credentials | `oauth2_client_credentials` | Machine-to-machine OAuth2, no user redirect.                                                                                                           |
| Two-step                  | `two_step`                  | Exchange stored credentials for a short-lived token at dispatch (`application/connector/twostep/`).                                                    |
| OAuth1                    | `oauth1`                    | OAuth 1.0a request signing (`application/connector/oauth1/`).                                                                                          |
| JWT                       | `jwt`                       | Mint a signed JWT per request, optionally exchanged for an access token (`application/connector/jwtauth/`).                                            |
| MCP OAuth2                | `mcp_oauth2`                | OAuth2 for Model Context Protocol servers, with RFC 8414 metadata discovery and RFC 7591 dynamic client registration overlaid on the base OAuth2 flow. |
| Custom                    | `custom`                    | Provider-specific scheme handled entirely by provider hooks.                                                                                           |

For the secret-text / basic / custom types the provider supplies a
`CredentialSchema` — a list of `CredentialField`s (name, label, type,
`Required`, `Sensitive`, validation, placeholder). Fields marked `Sensitive` are
encrypted and never returned by the API.

## AuthContext resolution at dispatch [#authcontext-resolution-at-dispatch]

When an action is dispatched, `ConnectionService.Resolve(ctx, tenantID, connID)`
(`application/connector/connection_resolve.go`) produces a request-scoped
`AuthContext`. This is the only place stored credentials are decrypted, and the
resulting context is explicitly forbidden from being logged, stored, serialized,
or passed across process boundaries.

The flow:

<Steps>
  <Step>
    **Cache + singleflight.** An LRU cache (default max-age \~60s) short-circuits
    repeat resolves; concurrent resolves of the same connection collapse via
    `singleflight` keyed on `tenantID/connID`.
  </Step>

  <Step>
    **Load and status-gate.** `store.Get` returns the connection. An `active`
    connection proceeds; `pending` → `ErrConnectionPending`; `expired` / `revoked`
    / `errored` → `ErrConnectionExpired`.
  </Step>

  <Step>
    **Decrypt.** `crypto.DecryptContext(ctx, ciphertext, tenantID, providerKey)`
    decrypts the credential blob into `AuthContext.Secret`. Non-secret JSONB maps
    (`ConnectionConfig`, `Tags`, `Metadata`) are hydrated separately.
  </Step>

  <Step>
    **Per-auth decoration.** A switch on `AuthType` runs the right decorator:

    * `two_step` → POST the provider's token URL and stamp `Secret[two_step_token]`.
    * `jwt` → sign a JWT (with optional token exchange) and stamp `Secret[jwt_token]`.
    * `oauth1` → attach the provider's OAuth1 config; signing happens at egress.
    * `none` / `secret_text` / `basic` / `oauth2` / `oauth2_client_credentials` /
      `mcp_oauth2` / `custom` → no decorator.
  </Step>

  <Step>
    **OAuth2 inline refresh.** For `oauth2`, if the token expires within 60s, the
    service refreshes it under a per-connection singleflight (`refresh/<key>`),
    preferring the provider's `Hooks.RefreshToken` and falling back to a standard
    OAuth2 refresh. A successful refresh is persisted atomically
    (`BumpAndUpdateOAuthTokens`) and recorded as a lifecycle event; an
    `invalid_grant` marks the connection reauth-required.
  </Step>
</Steps>

`AuthContext.BearerToken()` resolves the usable token in priority order: the
OAuth2 access token, then `access_token`, `two_step_token`, or `jwt_token` from
the secret map. OAuth1 intentionally returns an empty bearer — its requests are
signed by the HTTP client at egress instead.

<Callout type="info" title="Where each auth mode is wired">
  The two-step, JWT, OAuth1, and MCP-OAuth handlers are wired as default-off `fx`
  modules in `cmd/ductor/` (`fx_connector_twostep.go`, `fx_connector_jwtauth.go`,
  `fx_connector_oauth1.go`, `fx_connector_mcpoauth.go`). They are constructed at
  startup but only fire when a connection of the matching auth type is resolved,
  so a deployment that uses only API keys pays nothing for them.
</Callout>

## Credential encryption at rest [#credential-encryption-at-rest]

Every stored credential is sealed with authenticated encryption before it touches
the database.

* **Algorithm: XChaCha20-Poly1305** (the 24-byte-nonce X variant, not plain
  12-byte ChaCha20-Poly1305). Keys are 32 bytes, nonces are 24 bytes drawn from
  `crypto/rand` per message, and the Poly1305 tag is 16 bytes.
* **Tenant-bound AAD.** The additional authenticated data binds each ciphertext
  to `tenantID` and `providerKey`. Decryption with the wrong tenant or provider
  fails loudly — a ciphertext cannot be replayed across tenants.
* **Plaintext never persists.** The `connector_connection` row holds only
  `CredentialsEnc` (and `OAuthEnc` for OAuth tokens). Decryption happens
  in-process, at dispatch, into the throwaway `AuthContext`.

### Envelope formats [#envelope-formats]

The crypto layer supports three on-the-wire envelope formats, dispatched on the
first byte, so key derivation and rotation can evolve without a data migration:

| Format      | Magic  | Key derivation                                                                                                                         |
| ----------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| v0 (legacy) | none   | Cipher key is the raw master key.                                                                                                      |
| v1          | `0xF1` | Cipher key = HKDF-SHA256(master, info=`Purpose`). Connector credentials use purpose `ductor/connector-credentials/v1`.                 |
| v2          | `0xF2` | Per-message ephemeral data-encryption key, wrapped under a **per-tenant** key-encryption key (KEK). Enables BYOK and crypto-shredding. |

### Configuring the key [#configuring-the-key]

The master key is loaded once at startup by `provideConnectorCrypto`
(`cmd/ductor/fx_connector.go`). Resolution order:

1. **BYOK** — when `keyProvider.enabled` is set, the base64 `wrappedMaster` is
   unwrapped through the external key provider (`kp.Decrypt(keyRef, wrapped)`) and
   must yield exactly 32 bytes. No silent fallback.
2. **Static key** — `connector.encryption_key`, or the environment variable
   `DUCTOR_CONNECTOR_ENCRYPTION_KEY`. The value is **standard base64 that must
   decode to exactly 32 bytes**.
3. **Development fallback** — only if the key is unset *and* `DUCTOR_ENV=development`
   *and* the security profile isn't enterprise, a deterministic dev key is
   derived. In every other environment a missing key is a startup error.

<Callout type="warn" title="Protect the encryption key">
  `DUCTOR_CONNECTOR_ENCRYPTION_KEY` decrypts every stored credential. Treat it
  like a database password: inject it from your secret manager, never commit it,
  and prefer a KMS-backed BYOK setup in production — the loader even logs a
  warning that an env-supplied key is visible in `/proc/<pid>/environ`. Losing
  the key means losing access to every stored connection; leaking it exposes all
  of them.
</Callout>

### Key rotation [#key-rotation]

Rotation is decrypt-with-many, encrypt-with-one. The `ConnectorCryptoConfig`
carries an `ActiveKeyID` (written into every new ciphertext) plus a `Keyring` of
older keys retained for decryption only. Each ciphertext records the key id it
was sealed with (`connector.encryption_key_id` for the active key,
`connector.rotation_keys` for the retired ones), so during a rotation window both
old and new ciphertexts decrypt correctly and reads transparently re-seal under
the active key on the next write.

Two subtleties matter when rotating:

* The **key id** is on the wire; the **HKDF purpose is not**. The purpose label
  must stay stable across a rotation, or existing ciphertext becomes
  undecryptable.
* Retired keys stay in the keyring until you're confident no ciphertext still
  references them. Rotating the active key does not re-encrypt existing rows
  eagerly — it happens lazily on write.

### BYOK and crypto-shredding (envelope v2) [#byok-and-crypto-shredding-envelope-v2]

For per-tenant key isolation, envelope v2 wraps a fresh data key per message
under a tenant KEK:

* `TenantKEKCache` is an expirable LRU (default 512 entries, 15-minute TTL) with
  `singleflight` loading.
* `TenantKEKResolver` loads a tenant's wrapped KEK from the KEK store and unwraps
  it through the external key provider; an un-provisioned or soft-deleted tenant
  returns an error rather than auto-provisioning.
* `EvictingTenantKEKStore` evicts the cache on `Upsert`/`SoftDelete`/`Destroy`
  and can broadcast the eviction to other pods over Redis. A `Destroy`
  (irreversible key overwrite — a crypto-shred) broadcasts fail-closed: every
  credential for that tenant becomes permanently undecryptable.

## Credential lifecycle and health [#credential-lifecycle-and-health]

Beyond raw storage, connections carry a projected health signal
(`domain/connector/credential_lifecycle.go`) so routing can avoid connections
that will fail. A `CredentialHealthGrade` is one of `unknown`, `healthy`,
`degraded`, `unhealthy`, or `reauth_required`, projected from connection state:
a revoked or expired connection, exhausted refreshes, or missing scopes grade
`reauth_required`; an errored connection grades `unhealthy`; recent refresh
failures grade `degraded`. A connection is `readyForRouting` only when its
lifecycle policy is active, the connection is active, and it is healthy (or
`degraded` when the policy allows routing to unhealthy connections). Lifecycle
transitions (`oauth_refresh_succeeded`, `auth_failed`, `reauth_required`,
`connection_revoked`, …) are emitted as `CredentialLifecycleEvent`s.

## Where to go next [#where-to-go-next]

* [Connections](/docs/connectors/connections) — creating connections and the OAuth start/callback API.
* [Architecture](/docs/connectors/architecture) — where resolution sits in the dispatch flow.
* [Policies, Quotas & Audit](/docs/connectors/policies-quotas-audit) — tenant-level BYO OAuth overrides.
