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-framework/testing";
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.
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-configbacking: "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: ein dummy "billing"-feature mit einem per-tenant
// Stripe-API-Key. Der `mask`-Eintrag lässt den configEdit-Screen +
// Settings-Hub-Nav automatisch entstehen (kein handgeschriebenes
// r.screen/r.nav mehr). charge-handler liest den Key über ctx.config
// (entschlüsselt automatisch).
//
// Production: nicht aufrufen — der echte Stripe-Call ist hier ein Mock.
// Pattern in produktiven Apps: Strip-API-Key in tenantBillingConfig.key,
// charge-handler lädt key + ruft tatsächlich Stripe.
import {
access,
type ConfigKeyDefinition,
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: { title: "billing.stripe-api-key", order: 1 },
});
const stripeApiKeyHandle: ConfigKeyHandle<"text"> = {
name: `${FEATURE}:config:stripe-api-key`,
type: "text",
};
const billingConfigKeyMap: Record<string, ConfigKeyDefinition> = {
"stripe-api-key": stripeApiKeyDef,
};
// Charge-Handler — nutzt den entschlüsselten API-Key. Caller sieht NUR
// die charge-id zurück, der Key bleibt 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: real impl würde fetch("https://api.stripe.com/v1/charges",
// { headers: { Authorization: `Bearer ${apiKey}` }, ... }) ausführen.
// Wichtig: apiKey verlässt den server NICHT — kein log, kein
// response-field, kein 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("config");
r.config({ keys: billingConfigKeyMap });
r.writeHandler(chargeHandler);
// Kein r.screen/r.nav: der `mask`-Eintrag auf dem Key lässt
// buildConfigFeatureSchema den configEdit-Screen (••••••-maskiert,
// config:write:set verschlüsselt vor dem write) + den Settings-Hub-Nav
// automatisch ableiten.
});
// Re-exports damit tests die handles ohne re-typing nutzen können.
export { stripeApiKeyHandle };

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