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
| Object | Rendered when | Purpose |
|---|---|---|
Secret thoryn-identity-tenant-smtp-credential-encryption-key | identity.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_ENABLED | always | Master switch on identity-service |
Env OAUTHY_EMAIL_TENANT_SMTP_FAIL_CLOSED | always | Fail-safe behaviour when a tenant's transport cannot be built |
Env OAUTHY_EMAIL_TENANT_SMTP_CREDENTIAL_ENCRYPTION_KEY | always | secretKeyRef 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
-
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.
-
Flip the flag in the environment's values file —
values-staging.yamltoday,values-production.yamlwhen the production pipeline lands:identity: tenantSmtp: enabled: trueIn the values file, in git — not
kubectl edit, not a CI variable. The activated state has to be visible as a diff onmain, and it is one line. -
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.
-
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):
| Method | Path | Description |
|---|---|---|
GET | /internal/tenants/{tenantId}/email-provider | Read the tenant's config. Never returns the password — not even the ciphertext. |
PUT | /internal/tenants/{tenantId}/email-provider | Create or merge-update the config, including the per-tenant enabled flag. |
DELETE | /internal/tenants/{tenantId}/email-provider | Remove 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:
- 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. - 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.
- 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.credentialEncryptionKeyto 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
lookupfinds nothing and generates a fresh key. - Restoring Postgres into a fresh cluster or namespace without also restoring this
Secret first. The
encryption-key-backupCronJob captures it (SSO-2129), but you must restore the key Secret before the firsthelm upgradeof the rebuilt environment — otherwise the chart'slookupmints 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:
- Inventory the affected tenants: the rows in
tenant_email_providerwith 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). - Optionally set
identity.tenantSmtp.enabled: falseand deploy first. Every tenant falls back to the platform sender explicitly, rather than drifting there through decrypt failures while you work. - Rotate: bump
credentialEncryptionKeyVersion(or set an explicit newcredentialEncryptionKey) and deploy. - Re-enter each tenant's SMTP password through
PUT /internal/tenants/{tenantId}/email-provider. The write encrypts under the new key. - 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
| Symptom | Cause | Fix |
|---|---|---|
| identity-service refuses to start, complaining the credential-encryption key is unset or the all-zero dev key | The feature is enabled but the Secret is missing or empty in a non-dev/test profile | Do 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 relay | The master switch is off, or the tenant's own enabled flag is off, or the config row is incomplete | Check 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 relay | The credential-encryption key changed — its stored password can no longer be decrypted | See 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 key | Remove 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.