This is effectively a guest post from CM.com, which uses KumoMTA as its email infrastructure. Their Engineering team shared this with us as an example of how they integrated OpenBao as a KeySource backend for KumoMTA and we are excited to share it with the community. The full submission is reproduced below, mostly unchanged (edited for formatting only). Enjoy.
Using OpenBao as a KeySource backend for KumoMTA
Credit: this write-up is based on integration and migration work done at CM.com by Adithya V Hebbar, Software Engineer, CM.com, with the OpenBao cluster itself set up and operated by Cyril Mengin and the CM.com Ops team.
OpenBao is a Linux Foundation-governed, open-source fork of HashiCorp Vault, created after Vault's 2023 license change to BSL. Because OpenBao implements the same KV v2 HTTP API as Vault, it works as a drop-in replacement for every vault_* KeySource field documented in the KumoMTA KeySource reference. No KumoMTA-side code changes are required.
We verified this in a production environment for two separate KumoMTA integration points:
- DKIM signing keys, via
kumo.dkim.rsa_sha256_signer'skeyfield - SMTP AUTH credentials, via a direct
kumo.secrets.loadcall inside asmtp_server_auth_plainhandler
How we set it up
-
We stood up an OpenBao server or cluster. In this case, the deployment was OpenBao v2.4.4 via the official Helm chart, chart version 0.19.3. The cluster is shared and operated by the platform and ops team, with each internal team assigned its own namespace.
-
We enabled a KV v2 secrets engine at a mount path:
$ bao secrets enable -path=secret kv-v2 -
We wrote a policy that scopes read access to only the paths KumoMTA needs:
path "secret/data/dkim/*" { capabilities = ["read"] } path "secret/data/smtp-auth/*" { capabilities = ["read"] } # lets the token renew/look itself up instead of going stale path "auth/token/*" { capabilities = ["create", "read", "update", "list"] } -
We created an AppRole tied to that policy, then generated a token from it. The
role_idandsecret_idact roughly like a one-time username and password pair used to mint the token. We then made the resulting token available to thekumodprocess throughVAULT_ADDRandVAULT_TOKEN. -
We pointed KumoMTA's existing Vault-shaped configuration fields at the OpenBao endpoint exactly as if it were HashiCorp Vault. That was the full change.
Configuration examples
Secrets redacted.
DKIM signer
local vault_signer = kumo.dkim.rsa_sha256_signer {
key = {
vault_mount = 'secret',
vault_path = 'dkim/' .. msg:from_header().domain,
-- Same as HashiCorp Vault: if omitted, values are read from
-- $VAULT_ADDR / $VAULT_TOKEN. These must be accessible to the
-- kumod user/service.
-- vault_address = "https://vault.internal.example.com"
-- vault_token = "hvs.TOKENTOKENTOKEN" -- OpenBao issues the same token format
-- vault_key = "my_custom_key_name"
},
}
Populate it with the OpenBao CLI, which mirrors Vault syntax:
$ bao kv put -mount=secret dkim/example.org key=@example-private-dkim-key.pem
We have not specifically verified the original HashiCorp vault binary against an OpenBao endpoint. We only used bao, which is the supported and recommended CLI for OpenBao. In principle, the original Vault CLI should also work because the wire API is compatible, but treat that as unconfirmed rather than tested.
SMTP AUTH credential check
This example uses kumo.secrets.load directly inside a smtp_server_auth_plain handler:
kumo.on('smtp_server_auth_plain', function(authz, authc, password, conn_meta)
local ok, secret = pcall(kumo.secrets.load, {
vault_address = os.getenv 'VAULT_ADDR',
vault_token = os.getenv 'VAULT_TOKEN',
vault_mount = 'secret',
vault_path = 'smtp-auth/' .. authc,
vault_key = 'password', -- field name is up to you; defaults to "key"
})
if not ok then
return false
end
return secret == password
end)
Token refresher
This pattern keeps the AppRole-issued token alive using KumoMTA's own task and event primitives, with no external sidecar or cron required:
-- token_refresher.lua
local kumo = require 'kumo'
local http_client = kumo.http.build_client({})
local mod = {}
local function renew_token(vault_addr)
local token = os.getenv 'VAULT_TOKEN'
local base = vault_addr:sub(-1) == '/' and vault_addr or (vault_addr .. '/')
local req = http_client:post(base .. 'v1/auth/token/renew-self')
req:headers { ['X-Vault-Token'] = token }
local resp = req:send()
if not resp:status_is_success() then
error(string.format('token renew failed: %s', resp:text()))
end
end
-- registers the periodic renewal loop as a named event
function mod.register(delay)
local vault_addr = os.getenv 'VAULT_ADDR'
local delay_time = (not delay or delay <= 0) and 14400 or delay -- default 4h
kumo.on('token_refresher', function()
while true do
local ok, err = pcall(renew_token, vault_addr)
if not ok then
kumo.log_error('token_refresher: renewal failed: ', tostring(err))
else
kumo.log_info 'token refreshed successfully'
end
kumo.time.sleep(delay_time)
end
end)
end
-- kicks off the loop as a background task
function mod.start()
kumo.spawn_task { event_name = 'token_refresher', args = {} }
end
return mod
Wiring it up in init.lua
local token_refresher = require 'token_refresher'
-- registers the handler at module load time
token_refresher.register()
kumo.on('init', function()
-- ...rest of init...
-- starts the background loop once kumod is initialized
token_refresher.start()
end)
This hits OpenBao's auth/token/renew-self endpoint directly, the same endpoint exposed by Vault, on a fixed interval using the token's own renewal capability. That is why the AppRole policy needs auth/token/* read and update access. There is no dependency on an OpenBao or Vault Agent sidecar.
Gotchas and tips
- KV v2
data/segment: KumoMTA's Vault client adds thedata/segment internally for the KV v2 API. Yourvault_mountandvault_pathvalues should match what you would pass tovault kv putorbao kv put, with nodata/segment included, even though your ACL policy paths do need it. For example:secret/data/dkim/*. - Namespaces: If you use OpenBao's namespace feature, note that KumoMTA KeySource has no dedicated
vault_namespacefield and only supports the standard HashiCorp Vault-shaped fields. There is no separate namespace header option. A practical workaround is to prefix the namespace directly into the mount path:
vault_mount = 'my-namespace/secret', -- "<namespace>/<mount>"
That was sufficient in practice on a shared OpenBao cluster where each internal team had its own namespace.
- AppRole over static tokens: Rather than minting one long-lived broad token, use a scoped flow of policy to AppRole to
role_id/secret_idto token. That keeps the token available tokumodlimited to the read capabilities it actually needs, plusauth/token/*so it can renew itself through the refresher process. Scope read policies narrowly by path prefix, such as separate rules fordkim/*andsmtp-auth/*. - Migrating existing secrets from Vault: If you are moving an existing Vault-backed KumoMTA setup to OpenBao rather than starting fresh, the data copy is straightforward because both systems speak the same KV v2 API. Options considered here included medusa, a purpose-built Vault KV import/export CLI that can pipe
medusa export | medusa importdirectly from Vault to OpenBao; a short script driving thevaultandbaoCLIs directly; or a small custom program using an HTTP client library such as VaultSharp for tighter batching and retry control. For a small number of secrets, any of the three approaches works well, and the CLI-script route requires the least new tooling.
Credits: CM.com; Adithya V Hebbar, Software Engineer, CM.com; Cyril Mengin and the CM.com Ops team for OpenBao cluster setup and operation.
We love user stories. If your team has done something awesome with KumoMTA, please let us know.
- - - - - - - - -
KumoMTA is the first open-source MTA designed from the ground up for the world's largest commercial senders. We are fueled by Professional Services and Sponsorship revenue.
Join the Forum | Review the Docs | Read the Blog | Grab the Code | SWAG Shop