Skip to content

Product documentation

Tenant BYO-SMTP activation

Turn on per-tenant bring-your-own SMTP: what the chart provisions, how to activate an environment, how to verify the three outcomes that matter, and why rotating the credential-encryption key is destructive.

Tenant BYO-SMTP activation

Bring-your-own SMTP lets a tenant send its transactional mail — password reset, magic link, email verification, invitation, account unlock, security incident — through its own SMTP relay instead of the platform relay, so the mail leaves the tenant's own domain and reputation. Every tenant without a configured relay keeps the platform sender, unchanged.

The capability ships off by default in every environment. Activation is two deploy-time things — a feature flag and a credential-encryption key — and both are chart-owned, so a from-scratch deploy reproduces an activated environment with no manual step.

What the chart provisions for you

ObjectRendered whenPurpose
Secret thoryn-identity-tenant-smtp-credential-encryption-keyidentity.enabled (independently of the feature flag)The 32-byte AES-256-GCM key that encrypts each tenant's SMTP password at rest
Env OAUTHY_EMAIL_TENANT_SMTP_ENABLEDalwaysMaster switch on identity-service
Env OAUTHY_EMAIL_TENANT_SMTP_FAIL_CLOSEDalwaysFail-safe behaviour when a tenant's transport cannot be built
Env OAUTHY_EMAIL_TENANT_SMTP_CREDENTIAL_ENCRYPTION_KEYalwayssecretKeyRef into the Secret above — never a values-file literal

The key Secret is deliberately rendered even while the feature is off. It is generated once, at first install, and re-used on every helm upgrade via a Helm lookup — the same pattern as the MFA-phone and OATH-seed keys. So the key is already in place and stable before any tenant credential is written under it, and activating later is a pure flag flip with no key-generation event.

You do not create this Secret by hand. kubectl create secret for it is the reproducibility anti-pattern the operate overview forbids: the next clean deploy would not reproduce it.

Activate an environment

  1. Confirm the product-owner decision. Enabling BYO-SMTP changes which relay a tenant's mail leaves from. It is a product call, not an ops convenience.

  2. Flip the flag in the environment's values filevalues-staging.yaml today, values-production.yaml when the production pipeline lands:

    identity:
      tenantSmtp:
        enabled: true

    In the values file, in git — not kubectl edit, not a CI variable. The activated state has to be visible as a diff on main, and it is one line.

  3. Deploy. The credential-encryption key Secret is created (first deploy) or preserved (every subsequent deploy). Nothing else is required — no repo secret, no manual step.

  4. Verify the three outcomes in Verify below.

Activation on an environment where no tenant has a BYO-SMTP config is a no-op for mail routing: with nothing configured, the resolver returns the platform transport for every tenant exactly as before.

Why the pod refuses to start if you get this wrong

AesGcmSmtpCredentialCipher validates the key eagerly, but only when the feature is enabled. In a non-dev/test profile, an enabled service with an unset key (or the all-zero dev key) throws at startup rather than encrypting tenant SMTP passwords under a publicly-known key. That is the structural backstop behind the chart wiring — an environment cannot silently activate with worthless at-rest protection. With the feature off, no key is needed to boot.

Configuring a tenant

Activation only opens the door; each tenant's relay is configured through the product config plane, on identity-service's internal tenant facade (projected-ServiceAccount-token gated, RFC 9457 errors):

MethodPathDescription
GET/internal/tenants/{tenantId}/email-providerRead the tenant's config. Never returns the password — not even the ciphertext.
PUT/internal/tenants/{tenantId}/email-providerCreate or merge-update the config, including the per-tenant enabled flag.
DELETE/internal/tenants/{tenantId}/email-providerRemove the config; the tenant falls back to the platform sender.

The submitted SMTP password is AES-256-GCM encrypted before it reaches Postgres (tenant_email_provider), is decrypted in memory for the duration of a single send, and is never logged and never returned by a read.

Configuration

Chart values (identity.tenantSmtp.*), and the identity-service properties they render:

identity:
  tenantSmtp:
    # → OAUTHY_EMAIL_TENANT_SMTP_ENABLED → thoryn.email.tenant-smtp.enabled
    # Master switch. false → platform sender for every tenant.
    enabled: false
    # → OAUTHY_EMAIL_TENANT_SMTP_FAIL_CLOSED → thoryn.email.tenant-smtp.fail-closed
    # false → a transport that cannot be built falls back to the platform sender
    # rather than dropping the mail. An SSRF-blocked target always fails the send
    # regardless of this flag.
    failClosed: false
    # → the chart Secret → OAUTHY_EMAIL_TENANT_SMTP_CREDENTIAL_ENCRYPTION_KEY
    #   → thoryn.email.tenant-smtp.credential-encryption-key
    # Normally EMPTY — the chart generates and preserves the key. Read the
    # rotation section before setting this.
    credentialEncryptionKey: ""
    # Bump ONLY to force-rotate. Destructive — see below.
    credentialEncryptionKeyVersion: "1"

For staging the key can also be pinned from the repo secret IDENTITY_TENANT_SMTP_CREDENTIAL_ENCRYPTION_KEY, which deploy-staging.yml threads as --set-string identity.tenantSmtp.credentialEncryptionKey=.... Leave it unset unless you are rebuilding a cluster and must reproduce an existing key: when unset it renders empty and the chart preserves whatever is already installed.

Verify

Three outcomes, in this order:

  1. A tenant with an active config sends via its own relay. Trigger a password reset for a user in that tenant and check the receiving mailbox's headers — the message should show the tenant's relay and From:, not the platform sender.
  2. An unconfigured tenant is byte-for-byte unchanged. Trigger the same flow for a user in a tenant with no config. It must still go out through the platform relay. This is the guarantee that makes activation safe.
  3. An SSRF-blocked target fails the send. A config pointing at a private, loopback, link-local or cloud-metadata address must fail — never fall back to the platform relay. Falling back would let a tenant use the platform's egress to reach an internal host.

Outcome 3 is not tunable: failClosed governs build/decrypt failures, and an SSRF-blocked target fails regardless of it.

Rotating the credential-encryption key

Rotation is destructive. Read this before you bump anything.

Every stored tenant SMTP password is AES-256-GCM ciphertext under a single key. The stored token carries no key-version prefix, and there is no dual-read window. A new key does not re-key anything — it makes every existing ciphertext undecryptable. The affected tenants' transports then fail to build and (with the default failClosed: false) fall back silently to the platform sender: mail keeps flowing, from the wrong relay, until someone notices.

This is the sharp edge of the whole feature. It differs from the platform's signing keys, where a kid / version prefix makes rotation non-destructive (Signing and crypto).

There are three ways to rotate by accident. Avoid all three:

  • Bumping identity.tenantSmtp.credentialEncryptionKeyVersion.
  • Setting identity.tenantSmtp.credentialEncryptionKey to a value different from the one already installed — including via the staging repo secret.
  • Deleting the Secret (or the namespace) so the next deploy's lookup finds nothing and generates a fresh key.
  • Restoring Postgres into a fresh cluster or namespace without also restoring this Secret first. The encryption-key-backup CronJob captures it (SSO-2129), but you must restore the key Secret before the first helm upgrade of the rebuilt environment — otherwise the chart's lookup mints a fresh key and the restored ciphertext meets the wrong one. See Backup and recovery → the per-service encryption keys.

If you must rotate — key exposure, or a compliance-driven re-key — the only correct procedure is to re-establish every tenant's credential under the new key:

  1. Inventory the affected tenants: the rows in tenant_email_provider with a stored credential. Notify them; they must be able to re-supply the SMTP password (the platform cannot recover it — that is the point of the at-rest encryption).
  2. Optionally set identity.tenantSmtp.enabled: false and deploy first. Every tenant falls back to the platform sender explicitly, rather than drifting there through decrypt failures while you work.
  3. Rotate: bump credentialEncryptionKeyVersion (or set an explicit new credentialEncryptionKey) and deploy.
  4. Re-enter each tenant's SMTP password through PUT /internal/tenants/{tenantId}/email-provider. The write encrypts under the new key.
  5. Re-enable the flag if you disabled it in step 2, and re-run all three Verify checks.

A forward-compatible fix — a key-version prefix on the stored token, so rotation becomes a dual-read window instead of a re-entry campaign — is not implemented today. Until it is, treat this key as long-lived.

Troubleshooting

SymptomCauseFix
identity-service refuses to start, complaining the credential-encryption key is unset or the all-zero dev keyThe feature is enabled but the Secret is missing or empty in a non-dev/test profileDo not work around it. Deploy the chart so the Secret is provisioned; confirm the pod's OAUTHY_EMAIL_TENANT_SMTP_CREDENTIAL_ENCRYPTION_KEY resolves via secretKeyRef
A configured tenant still sends from the platform relayThe master switch is off, or the tenant's own enabled flag is off, or the config row is incompleteCheck OAUTHY_EMAIL_TENANT_SMTP_ENABLED on the pod, then GET /internal/tenants/{tenantId}/email-provider
A tenant that worked yesterday now silently sends from the platform relayThe credential-encryption key changed — its stored password can no longer be decryptedSee rotation. Re-enter the tenant's password; do not rotate again
helm upgrade fails with duplicate entries for key OAUTHY_EMAIL_TENANT_SMTP_...One of these env vars was also set in identity.env; Server-Side Apply rejects a duplicate env keyRemove it from identity.env and use identity.tenantSmtp.*. The chart now fails at render time with this message instead

Security notes

  • The tenant's SMTP password is a credential and is treated as one. It is envelope-encrypted at rest, decrypted in memory for a single send, never logged, never returned by a read, and never present in a values file or ConfigMap.
  • The encryption key never appears in git. It is chart-generated into a Kubernetes Secret and injected by secretKeyRef. The values key exists only so a DR rebuild can reproduce a known key.
  • The SSRF boundary is not tunable. Tenant-supplied SMTP hosts are validated against the same private/loopback/link-local/metadata ranges as every other outbound target (SSRF and outbound). A blocked target fails the send; it never falls back to platform egress. The operator allow-list exists for self-managed deployments whose relay legitimately sits on an internal range — a broad entry there re-opens the SSRF risk.
  • Rotation is destructive, per the section above. It is the one operation on this feature that can lose customer configuration.