Integrating OpenBao with KumoMTA

  • September 10, 2026

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's key field
  • SMTP AUTH credentials, via a direct kumo.secrets.load call inside a smtp_server_auth_plain handler

How we set it up

  1. 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.

  2. We enabled a KV v2 secrets engine at a mount path:

    $ bao secrets enable -path=secret kv-v2
  3. 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"]
    }
  4. We created an AppRole tied to that policy, then generated a token from it. The role_id and secret_id act roughly like a one-time username and password pair used to mint the token. We then made the resulting token available to the kumod process through VAULT_ADDR and VAULT_TOKEN.

  5. 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 the data/ segment internally for the KV v2 API. Your vault_mount and vault_path values should match what you would pass to vault kv put or bao kv put, with no data/ 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_namespace field 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_id to token. That keeps the token available to kumod limited to the read capabilities it actually needs, plus auth/token/* so it can renew itself through the refresher process. Scope read policies narrowly by path prefix, such as separate rules for dkim/* and smtp-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 import directly from Vault to OpenBao; a short script driving the vault and bao CLIs 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