Convex Secret Store

A Convex component for encrypted secret storage with
versioned key rotation, expiry, and append-only audit logging. Envelope
encryption runs inside the component; key-encryption keys (KEKs) are supplied
through Convex's typed component environment variables.
const saved = await secrets.put(ctx, {
namespace: "acme:production",
name: "openai",
value: process.env.OPENAI_API_KEY!,
metadata: { owner: "platform" },
});
const loaded = await secrets.get(ctx, {
namespace: "acme:production",
name: "openai",
});
if (loaded.ok) {
}
Found a bug? Feature request?
File it here.
What this gives you
- envelope encryption with a per-secret DEK and versioned KEKs
- explicit, in-place key rotation without rewriting secret plaintext
- expiry-aware reads, with separate
get (Result) and getOrThrow ergonomics
- append-only audit events for
created, updated, deleted, and rotated
- a typed
SecretStore facade for Convex queries, mutations, and actions
- structured
ConvexErrors with stable codes — type-narrowed via isSecretStoreError
Pre-requisite
A Convex project (convex >= 1.39, for typed component environment variables).
If you're new to Convex, start with the
Convex tutorial.
Installation
npm install convex-secret-store
Mount the component in your app's convex.config.ts. The simplest setup
declares the KEK as a validated app env var and reads it via defineKeys:
import { defineApp } from "convex/server";
import { v } from "convex/values";
import { defineKeys } from "convex-secret-store";
import secretStore from "convex-secret-store/convex.config";
const app = defineApp({
env: {
MY_APP_KEK_V1: v.string(),
},
});
app.use(secretStore, {
env: {
SECRET_STORE_KEYS: defineKeys({
1: process.env.MY_APP_KEK_V1!,
}),
},
});
export default app;
Generate a KEK and set it on the deployment:
openssl rand -base64 32
npx convex env set MY_APP_KEK_V1 "<paste the value>"
Construct the typed facade once and re-export it:
import { SecretStore } from "convex-secret-store";
import { components } from "./_generated/api.js";
export const secrets = new SecretStore<
`${string}:${"production" | "testing"}`,
{ owner?: string; label?: string }
>(components.secretStore);
Use it from any query, mutation, or action:
import { action } from "./_generated/server.js";
import { secrets } from "./secrets.js";
export const callOpenAI = action({
handler: async (ctx) => {
const apiKey = await secrets.getOrThrow(ctx, {
namespace: "acme:production",
name: "openai",
});
return await fetch("https://api.openai.com/v1/...", {
headers: { Authorization: `Bearer ${apiKey.value}` },
});
},
});
Key configuration
SECRET_STORE_KEYS
The component declares one required env var, SECRET_STORE_KEYS. Its value is a
delimited string of versioned KEKs:
1:<base64>,2:<base64>
Each entry is version:value. Each KEK value must be a canonical 32-byte
standard base64 string (44 chars ending in =) — the output of
openssl rand -base64 32.
You can build the value with defineKeys (typed, eagerly validated):
defineKeys({
1: process.env.MY_APP_KEK_V1!,
2: process.env.MY_APP_KEK_V2!,
})
…or set it directly on the deployment for hosts that don't want a builder:
npx convex env set SECRET_STORE_KEYS "1:AQEB...,2:AgIC..."
Either way, the active key version is the highest configured version — all
new writes use it. Older versions remain available for decrypting existing
rows.
Duplicate versions are caught at compile time when you use defineKeys (object
literals reject duplicate keys: error TS1117).
SECRET_STORE_DEFAULT_TTL_MS (optional)
If set, put resolves an omitted ttlMs to now + SECRET_STORE_DEFAULT_TTL_MS.
See Expiry below.
API
SecretStore
new SecretStore<
Namespace extends string = string,
Metadata extends Record<string, unknown> = Record<string, unknown>,
>(components.secretStore)
The optional type parameters constrain the namespace shape and the metadata
record at compile time. They're a typing aid only — runtime storage stays a
plain string namespace and a freeform metadata record.
put
await secrets.put(ctx, {
namespace?: Namespace,
name: string,
value: string, // plaintext; max 64 KiB
metadata?: Metadata | null,
ttlMs?: number | null,
});
// → { secretId, createdAt, updatedAt, expiresAt?, isNew }
Encrypts and upserts by namespace + name. On overwrite, omitted metadata is
preserved and null clears it. Expiry is recomputed every write (see below).
get
await secrets.get(ctx, { namespace?, name });
// → { ok: true, value, metadata, expiresAt?, updatedAt }
Returns a discriminated result. key_unavailable means the row's keyVersion
is no longer in your configured SECRET_STORE_KEYS (you retired the key too
soon — see Rotation).
getOrThrow
await secrets.getOrThrow(ctx, { namespace?, name });
// → { value, metadata, expiresAt?, updatedAt }
Convenience for call sites that treat any failure as exceptional.
No dedicated has is exposed. For "does X exist?" checks, derive from get:
const present = (await secrets.get(ctx, { name })).ok.
update
await secrets.update(ctx, {
namespace?, name,
metadata?: Metadata | null,
ttlMs?: number | null,
});
// → { updated, updatedAt?, expiresAt? }
Edits metadata and/or expiry without re-encrypting the value. Omitted
fields are preserved; null clears. At least one of metadata or ttlMs
must be supplied — calling with neither throws invalid_argument.
{ updated: false } means the secret does not exist (symmetric with remove).
remove
await secrets.remove(ctx, { namespace?, name });
// → { removed: boolean }
list
await secrets.list(ctx, {
namespace?,
paginationOpts: { numItems, cursor },
order?: "asc" | "desc",
});
Paginates secrets in a namespace, newest-updatedAt first by default. Each row
carries effectiveState: "active" | "expired". The decrypted value is never
returned by list.
listEvents
await secrets.listEvents(ctx, {
paginationOpts,
secretId?,
namespace?, // omit for the default namespace
name?, // optionally narrows within `namespace`
type?: "created" | "updated" | "deleted" | "rotated", // optionally narrows within `namespace`
order?,
});
secretId cannot combine with namespace/name/type; name and type
cannot combine with each other.
rotate
await secrets.rotate(ctx);
Rewraps each stale secret's DEK onto the active KEK version, in batches of 100
per transaction, self-rescheduling until the backlog drains. The secret
plaintext is never rewritten. Safe to call repeatedly.
skipped counts rows that reference a keyVersion no longer in
SECRET_STORE_KEYS — orphans from retiring a KEK too soon. They're left
untouched and logged. The chain stops rescheduling once only orphans remain
(otherwise it would spin); recover by re-adding the missing KEK and calling
rotate again, or by removeing the orphan rows.
isRotationComplete
await secrets.isRotationComplete(ctx);
true when every secret is on the active KEK version. Use this as the gate
before retiring an old key.
cleanupSecrets / cleanupEvents
await secrets.cleanupSecrets(ctx, { retentionMs? });
await secrets.cleanupEvents(ctx, { retentionMs? });
Hard-deletes expired secrets (and writes a deleted event with reason
expired_cleanup) / old audit events. Self-reschedules; suitable for a
recurring cron.
Expiry
expiresAt = now + ttlMs if ttlMs is a positive integer
= no expiry if ttlMs is null
= now + SECRET_STORE_DEFAULT_TTL_MS if ttlMs is omitted and a default is set
= no expiry otherwise
ttlMs (and SECRET_STORE_DEFAULT_TTL_MS) must be a positive integer — 0
is rejected. Expiry is recomputed on every put (ADR 0003); use update
to change expiry without rewriting the value.
Key rotation
Rotation is a deploy-time exercise. The component drains in the background once
triggered.
- Deploy with a new key. Add the new version to
defineApp({ env }) and
defineKeys, set the env var on the deployment, push:
const app = defineApp({
env: {
MY_APP_KEK_V1: v.string(),
MY_APP_KEK_V2: v.string(),
},
});
app.use(secretStore, {
env: {
SECRET_STORE_KEYS: defineKeys({
1: process.env.MY_APP_KEK_V1!,
2: process.env.MY_APP_KEK_V2!,
}),
},
});
npx convex env set MY_APP_KEK_V2 "$(openssl rand -base64 32)"
npx convex deploy
- Trigger rotation. Call
secrets.rotate(ctx) once (from the dashboard, a
one-shot internal mutation, or a temporary cron during the drain window).
- Verify drain. Poll
secrets.isRotationComplete(ctx) — when true, no
secret is still on an old key.
- Retire the old key. First remove it from
defineApp({ env }) and
defineKeys, then push, then npx convex env remove MY_APP_KEK_V1. The
order matters — Convex refuses to remove a deployment env var that's still
declared as required in the app definition.
A new rotated audit event is recorded per row.
KEK material is immutable per version. To rotate, add a new version
(MY_APP_KEK_V2) — never overwrite the material under an existing version
number. Replacing the bytes under MY_APP_KEK_V1 while rows still reference
it is unsupported: reads of those rows will throw decryption_failed, and
rotate will skip them with a warning. Recovery requires restoring the
original material under that version, or remove-ing the affected rows.
Cleanup crons
Recommended permanent crons:
import { internalMutation } from "./_generated/server.js";
import { secrets } from "./secrets.js";
export const cleanupSecrets = internalMutation({
handler: (ctx) => secrets.cleanupSecrets(ctx),
});
export const cleanupEvents = internalMutation({
handler: (ctx) => secrets.cleanupEvents(ctx),
});
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api.js";
const crons = cronJobs();
crons.interval("cleanup expired secrets", { hours: 24 }, internal.cleanup.cleanupSecrets);
crons.interval("cleanup audit events", { hours: 24 }, internal.cleanup.cleanupEvents);
export default crons;
If you'd rather skip the cleanup.ts wrapper, you can point crons.interval
directly at the component's function and pass retentionMs as a static arg:
import { components } from "./_generated/api.js";
crons.interval(
"cleanup expired secrets",
{ hours: 24 },
components.secretStore.cleanup.cleanupSecrets,
{ retentionMs: 30 * 24 * 60 * 60 * 1000 },
);
The wrapper above gets you cleaner names in logs and a place to hook in
behavior later (metrics, alerts); the direct form trades that for one fewer
file. Pick whichever your codebase prefers — both are supported.
Optional: rotation safety-net cron
By default the recommended rotation flow is one-shot — call
secrets.rotate(ctx) once after deploying a new key, then poll
isRotationComplete() before retiring the old one (see
Key rotation). If you'd rather not remember the manual step,
add a slow rotation cron as a safety net. It's a no-op when nothing's stale
(one indexed .first() query — cheap) and picks up any backlog automatically:
import { components } from "./_generated/api.js";
crons.interval(
"secret-store rotation",
{ hours: 24 },
components.secretStore.lib.rotate,
{},
);
Cadence caveat. Keep the interval at ≥ 24h. At sub-hourly cadence a
cron tick may fire while a previous chain is still draining a large backlog,
spawning parallel chains that waste compute (no correctness issue, since the
slice is self-consuming, just noise). Daily or weekly is the right place.
Errors
Every backend failure surfaces as a ConvexError whose data carries a typed
code:
import { isSecretStoreError, type SecretStoreErrorCode } from "convex-secret-store";
try {
await secrets.put(ctx, { name: "", value: "..." });
} catch (error) {
if (isSecretStoreError(error)) {
switch (error.data.code) {
case "invalid_argument": break;
case "value_too_large": break;
case "invalid_keys": break;
case "key_unavailable": break;
case "decryption_failed": break;
case "not_found": break;
case "expired": break;
}
}
}
Security model
convex-secret-store provides encryption at rest inside Convex — protecting
against an attacker who gets the raw database without your deployment's
configuration. It is not a hardware-backed key store and does not protect
against any actor with deployment access:
- KEKs live in the Convex deployment's env vars. The component runs in your
Convex deployment; "encryption inside the component" and "encryption outside
the component" both run on Convex servers — there is no meaningful trust
boundary between them. Anyone who can read your Convex deployment env can
decrypt the data.
- Supply-chain trust. A malicious version of this package, if installed,
could exfiltrate decrypted plaintext. Pin the dependency and review updates.
- AAD binding. AES-256-GCM ciphertext binds
namespace, name, and the
DEK's keyVersion as additional authenticated data, so ciphertext cannot be
relocated between rows.
- Plaintext metadata.
metadata, namespace, name, expiry, and audit
events are not encrypted. Do not store sensitive data in metadata.
- Audit events are advisory, not tamper-evident. Events live in the same
database as the secrets and have no append-only enforcement; treat them as a
forensic trail visible to anyone with deployment access, not as evidence of
record.
See docs/adr/0001-component-side-encryption.md for the architecture trade-off.
Example app
The example/ directory contains a small Vite + Convex app:
- Secrets — store, replace, preview (masked), and remove environment
secrets
- Activity — paginate the audit log
- Settings — version counts per scope, rotation controls, cleanup buttons,
and demo data seeding
The example mounts the component with defineKeys({ 1: process.env.MY_APP_KEK_V1!, 2: process.env.MY_APP_KEK_V2! }),
demonstrating the validated-env pattern.
Note: The example UI is intentionally simple and unauthenticated. In a
real app, gate secret read/write flows behind your auth and authorization
layer before exposing them to operators.
Development
npm install
npm run dev
npm run build
npm run typecheck
npm run lint
npm test
Architectural decisions are recorded in docs/adr/.
Domain terminology is in CONTEXT.md.