Skip to content

Recipe: Encrypted per-tenant config

What this shows: how a SaaS customer stores their own API key (Stripe, Slack webhook, SMTP password …) in their settings, without the platform operator being able to read the plaintext from the DB.

import { access, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
createTenantConfig("text", {
encrypted: true, // ← ciphertext in the DB
write: access.admin, // ← the tenant admin writes their own
read: access.admin, // ← nobody else sees it
mask: { title: "billing.stripe-api-key", order: 1 }, // ← derives the edit screen + nav
});

The config resolver decrypts on the ctx.config(handle) call using the EncryptionProvider from extraContext.configEncryption. Anyone without the master key (= the KUMIKO_SECRETS_MASTER_KEY_V<n> env) only sees base64 AES ciphertext in the DB.

The mask entry is all the UI needs: buildConfigFeatureSchema derives the configEdit screen (pre-filled from config:query:values, where the key comes back as ••••••) and its Settings-Hub nav entry from the registered key — no hand-written r.screen/r.nav. mask.title is the i18n key of the field label, mask.order its position.

  • Per-tenant API keys — Customer A uses THEIR Stripe account, Customer B uses THEIRS. The platform operator never makes Stripe calls on behalf of customers.
  • Webhook secrets — per-tenant Slack/Discord incoming webhook URL.
  • SMTP credentials — per-tenant mail server (see samples/showcases/publicstatus Phase 2).

Vs. r.secret / samples/recipes/secrets-demo

Section titled “Vs. r.secret / samples/recipes/secrets-demo”
PatternScopeUse case
r.secret (envelope encryption, KEK rotation)App-globalPlatform-owned secrets (e.g. the master Stripe key that bills ALL customers).
encrypted: true config key (this recipe)Per-tenantCustomer-owned secrets. Customer sets, rotates, and never sees another customer’s. Same envelope encryption + KEK keyring under the hood.

Combinable: the platform uses r.secret for its internal secrets, and in parallel customers have their tenant-owned encrypted: true config keys.

None. runProdApp / runDevApp wire the envelope cipher automatically as soon as a master key is present in the environment:

Terminal window
KUMIKO_SECRETS_MASTER_KEY_V1=$(openssl rand -base64 32)

That single key drives encrypted: true config keys, r.secret, and encrypted entity fields — one keyring, one rotation story. Only tests or custom boots pass their own cipher:

import { createConfigResolver } from "@cosmicdrift/kumiko-bundled-features/config";
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-testing/helpers";
const cipher = createTestEnvelopeCipher();
const configResolver = createConfigResolver({ cipher });
// extraContext: { configResolver, configEncryption: cipher, ... }
  1. Tenant admin sets stripe-api-key via config:write:set (scope tenant).
  2. DB row holds a JSON envelope (AES-GCM ciphertext + wrapped DEK + kekVersion) — backups and SELECT leak nothing.
  3. config:query:values returns •••••• for the encrypted key in the UI.
  4. Domain handler calls ctx.config(handle) → decrypted value in-process.
  5. Tenant B cannot charge with Tenant A’s key — per-tenant config row isolation.
  1. DB plaintext-free: SELECT value FROM config_values WHERE key = 'stripe-api-key' returns ONLY ciphertext. Backup files, DB dumps, postgres eavesdropping → no plaintext leak.
  2. UI mask: config:query:values returns "••••••" for encrypted keys. Even the admin allowed to SET the value doesn’t see it back. (If you need the value, go through ctx.config(handle) in the backend, not through the UI.)
  3. Tenant isolation: every tenant has its own entry — (key, tenantId) is unique in the config feature. Customer A NEVER sees Customer B’s API key.

Needs a running Postgres and TEST_DATABASE_URL set (e.g. postgres://postgres:postgres@127.0.0.1:5432/postgres, see demo/.env.example).

Terminal window
bun test src/__tests__/feature.integration.test.ts

Proves:

  • DB stores ciphertext, not plaintext
  • UI query masks encrypted values
  • Charge handler reads decrypted key and succeeds only when set
  • Tenant A’s key does not leak to Tenant B
  • mask derives the configEdit screen without hand-written r.screen

Config values carry their kekVersion, so rotation is operational, not cryptographic surgery:

  1. Add KUMIKO_SECRETS_MASTER_KEY_V2 to the environment (old key stays).
  2. Set KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION=2 — new writes use V2, old rows still decrypt via the keyring.
  3. Trigger the manual config:reencrypt job — it re-encrypts every encrypted config row onto the current version (and migrates any pre-envelope legacy rows). Idempotent, chunked, circuit-breaker on repeated failures.
  4. Only after the job reports failed: 0 remove V1 from the environment.

The same job doubles as the migration path away from the deprecated single-key CONFIG_ENCRYPTION_KEY format: keep the old env var as the decrypt fallback until the job has run once, then delete it.

  • Audit trail: secrets-demo tracks every secret read as an event. Not here — config keys are meant as “settings” (read frequent, audit overkill).
  • Backup encryption: the KUMIKO_SECRETS_MASTER_KEY_V<n> keyring must be backed up alongside every backup, otherwise the DB is worthless after restore. Operator’s job.
  • managed-config — backing: "secrets" for platform-owned keys + tenant cascade for SMTP defaults.
  • apps-cap-billing-demo — billing handlers that read per-tenant Stripe keys.

The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):

// Encrypted per-tenant config — Stripe API key pattern.
//
// Minimal showcase: a dummy "billing" feature with a per-tenant
// Stripe API key. The `mask` entry makes the configEdit screen +
// settings hub nav appear automatically (no more hand-written
// r.screen/r.nav). The charge handler reads the key via ctx.config
// (decrypted automatically).
//
// Production: do not call — the real Stripe call is a mock here.
// Pattern in productive apps: Stripe API key in tenantBillingConfig.key,
// the charge handler loads the key + actually calls Stripe.
import {
access,
type ConfigKeyHandle,
createTenantConfig,
defineFeature,
defineWriteHandler,
} from "@cosmicdrift/kumiko-framework/engine";
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
import { z } from "zod";
const FEATURE = "billing";
// Config-Key-Definition: encrypted=true → ciphertext in der DB. write/
// read: access.admin damit nur Tenant-Admin den Key setzt + reads sind
// nur backend-side via ctx.config (frontend sieht "••••••"). `mask` →
// buildConfigFeatureSchema derivt Screen + Settings-Hub-Nav.
const stripeApiKeyDef = createTenantConfig("text", {
encrypted: true,
write: access.admin,
read: access.admin,
// mask-derived screens carry no field constraints (maxLength, pattern) —
// set those separately in a configEdit schema if you need them enforced.
mask: { title: "billing.stripe-api-key", order: 1 },
});
const stripeApiKeyHandle: ConfigKeyHandle<"text"> = {
name: `${FEATURE}:config:stripe-api-key`,
type: "text",
};
// Charge handler — uses the decrypted API key. The caller sees ONLY
// the charge id back, the key stays server-side.
const chargeHandler = defineWriteHandler({
name: "charge",
schema: z.object({
amount: z.number().positive(),
customerRef: z.string().min(1),
}),
access: { roles: ["Admin"] },
async handler(event, ctx) {
if (!ctx.config) {
return writeFailure(
new UnprocessableError("config_unavailable", {
i18nKey: "billing.errors.configUnavailable",
}),
);
}
const apiKey = await ctx.config(stripeApiKeyHandle);
if (!apiKey || apiKey.length === 0) {
return writeFailure(
new UnprocessableError("stripe_key_missing", {
i18nKey: "billing.errors.stripeKeyMissing",
}),
);
}
// Mock: a real impl would execute fetch("https://api.stripe.com/v1/charges",
// { headers: { Authorization: `Bearer ${apiKey}` }, ... }).
// Important: apiKey does NOT leave the server — no log, no
// response field, no error detail.
const chargeId = `ch_${Date.now()}_${event.payload.customerRef}`;
return {
isSuccess: true as const,
data: { chargeId },
};
},
});
export const billingFeature = defineFeature(FEATURE, (r) => {
r.requires({ features: ["config"] });
r.config("stripe-api-key", stripeApiKeyDef);
r.writeHandler(chargeHandler);
// No r.screen/r.nav: the `mask` entry on the key lets
// buildConfigFeatureSchema derive the configEdit screen (••••••-masked,
// config:write:set encrypts before the write) + the settings hub nav
// automatically.
r.translations({
keys: {
"billing.settings": { de: "Abrechnung", en: "Billing" },
"billing.stripe-api-key": { de: "Stripe-API-Schlüssel", en: "Stripe API Key" },
},
});
});
// Re-exports so tests can use the handles without re-typing.
export { stripeApiKeyHandle };

📄 On GitHub: samples/recipes/encrypted-tenant-config/src/feature.ts