Convex Secret Store
Encrypted, namespace-scoped secret storage for Convex with key rotation, expiry, and audit logs.
git clone https://github.com/gaganref/convex-secret-store.git
cd convex-secret-storeThis upstream package has not been adapted or qualified for Bijection. Read the original documentation before adopting it.
Description
Encrypted, namespace-scoped secret storage for Convex with key rotation, expiry, and audit logs.
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: gaganref/convex-secret-store. License: See repository. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
A secret store component for Convex.
Explore the demo ↗Upstream overview
From the upstream package documentation.
A Convex component that provides encrypted secret storage with envelope encryption, versioned key rotation, and audit logging. It encrypts secrets using per-secret DEKs wrapped with versioned KEKs, allowing key rotation without rewriting encrypted data. The component includes expiry management, namespace-scoped organization, and a typed SecretStore class for use in Convex functions.
Use cases
From the upstream package documentation.
- Store API keys per workspace - Keep OpenAI, Stripe, or other service credentials encrypted and isolated by namespace like
acme:productionvsacme:testing - Rotate encryption keys without downtime - Update your master encryption keys and gradually rewrap existing secrets without touching the encrypted payloads
- Audit secret access patterns - Track all create, update, delete, and rotate operations with append-only audit events for compliance
- Expire sensitive credentials automatically - Set TTL values on secrets and clean up expired data with configurable retention windows
- Type-safe secret management - Define TypeScript types for your namespace patterns and metadata schemas to catch errors at compile time
How it works
From the upstream package documentation.
The component uses envelope encryption where each secret gets a fresh data encryption key (DEK) that encrypts the plaintext value, then wraps that DEK with a longer-lived key encryption key (KEK) from your configuration. You configure the SecretStore class with your base64-encoded AES keys and use it in Convex queries, mutations, and actions.
The main API methods include put() for storing encrypted secrets, get() for decrypting them, and rotateKeys() for rewrapping DEKs under newer KEKs without touching the encrypted payloads. The component maintains audit events for all operations and supports namespace-scoped organization with optional expiry timestamps.
Key rotation works by keeping multiple key versions in your configuration - the first key encrypts new secrets while older keys remain available for decryption. You run rotateKeys() to gradually rewrap existing secrets under the newer key version, then remove the old keys from your config once rotation completes.
Upstream package FAQ
How does the encryption work in Convex Secret Store?
Convex Secret Store uses envelope encryption with two layers: each secret gets a unique AES-GCM data encryption key (DEK), and that DEK is wrapped with your configured key encryption key (KEK). This allows key rotation by rewrapping DEKs without touching the encrypted secret data.
Can I rotate encryption keys without downtime?
Yes, Convex Secret Store supports zero-downtime key rotation. Add the new key version as the first entry in your keys array, deploy, then use the `rotateKeys()` method to rewrap existing secrets. Old keys remain available during the transition period.
How do I handle expired secrets?
The Convex Secret Store automatically handles expiry during reads - expired secrets return `{ ok: false, reason: 'expired' }`. Use the `cleanupSecrets()` method with a retention window to permanently delete expired secrets, and set up a cron job to run cleanup regularly.
What happens if I lose access to an encryption key version?
If you remove a key version from your configuration, secrets encrypted with that version become unreadable and return `{ ok: false, reason: 'key_version_unavailable' }`. Always rotate secrets to newer key versions before removing old keys from your configuration.
How do I organize secrets across multiple environments?
Use namespaces to separate secrets by environment or team, like `myapp:production` or `team-alpha:testing`. The Convex Secret Store TypeScript client can enforce namespace patterns at compile time while keeping secrets isolated at runtime.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Convex Secret Store with Bijection.
Read: https://bijection.com/components/convex-secret-store/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from gaganref/convex-secret-store, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
Convex Secret Store
A Convex component for encrypted secret storage with versioned key rotation, expiry, audit logging, and a typed server-side client.
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) {
// loaded.value -> plaintext in memory
// loaded.metadata -> plaintext metadata
}
What this component gives you:
- envelope encryption with a per-secret DEK and versioned KEKs
- explicit key rotation without rewriting secret plaintext
- expiry-aware reads and cleanup
- append-only audit events for create, update, delete, and rotate operations
- a typed
SecretStoreclass for use in Convex queries, mutations, and actions
Found a bug? Feature request? File it here.
Pre-requisite: Convex
You'll need an existing Convex project to use this component. Convex is a hosted backend platform, including a database, serverless functions, and the generated component wiring this package expects. If you haven't used Convex before, the Convex tutorial is a good place to start.
Installation
npm install convex-secret-store
Install the component in your app's convex.config.ts:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import secretStore from "convex-secret-store/convex.config.js";
const app = defineApp();
app.use(secretStore);
export default app;
Quick Start
Create a shared SecretStore instance:
// convex/secrets.ts
import { SecretStore } from "convex-secret-store";
import { components } from "./_generated/api.js";
export const secrets = new SecretStore<{
namespace: `${string}:${"production" | "testing"}`;
metadata: { owner?: string; label?: string; notes?: string };
}>(components.secretStore, {
keys: [
{ version: 2, value: process.env.SECRET_STORE_KEY_V2! },
{ version: 1, value: process.env.SECRET_STORE_KEY_V1! },
],
});
Use it inside your Convex functions:
// convex/integrations.ts
import { mutation, action } from "./_generated/server.js";
import { v } from "convex/values";
import { secrets } from "./secrets.js";
export const putOpenAIKey = mutation({
args: {
workspace: v.string(),
value: v.string(),
},
handler: async (ctx, args) => {
return await secrets.put(ctx, {
namespace: `${args.workspace}:production`,
name: "openai",
value: args.value,
metadata: { owner: "platform" },
});
},
});
export const callProvider = action({
args: { workspace: v.string() },
handler: async (ctx, args) => {
const secret = await secrets.get(ctx, {
namespace: `${args.workspace}:production`,
name: "openai",
});
if (!secret.ok) {
throw new Error(`Secret unavailable: ${secret.reason}`);
}
return {
authHeaderPreview: `Bearer ${secret.value.slice(0, 4)}...`,
};
},
});
How It Works
Each stored secret uses two encryption layers:
- DEK: a fresh random data-encryption key generated per secret value
- KEK: a longer-lived key-encryption key from your configured
keys
When you call put:
- the plaintext value is encrypted with a fresh DEK
- that DEK is wrapped with the active KEK
- Convex stores:
encryptedValueivwrappedDEKdekIvkeyVersion
When you call get:
- the row is loaded from Convex
- the stored
keyVersionselects the correct KEK - the DEK is unwrapped
- the secret value is decrypted in memory
When you rotate keys:
- the encrypted secret value does not change
- only the wrapped DEK layer is re-encrypted under a newer KEK
This is standard envelope-encryption behavior and keeps key rotation cheaper than full plaintext re-encryption.
Key Material
keys must contain base64-encoded 32-byte AES keys.
Generate one with:
openssl rand -base64 32
Rules:
- the first entry is the active key used for new writes
- later entries are decrypt-only keys used for old rows
- versions must be unique non-negative integers
- empty-string namespaces are rejected
Typed Options
The SecretStore class accepts a generic parameter for type-safe namespaces and
metadata:
export const secrets = new SecretStore<{
namespace: `${string}:${"production" | "testing"}`;
metadata: {
owner?: string;
label?: string;
notes?: string;
};
}>(components.secretStore, {
keys: [{ version: 1, value: process.env.SECRET_STORE_KEY_V1! }],
defaults: {
ttlMs: 30 * 24 * 60 * 60 * 1000,
},
logLevel: "warn",
});
Type options:
namespace— anystringsubtype. When set,namespacebecomes required on normal read/write/list operations.metadata— shape of the plaintext metadata object stored with each secret.
Note: These are compile-time type constraints only. Runtime storage is still flexible:
namespace→v.optional(v.string())metadata→v.optional(v.record(v.string(), v.any()))Existing stored rows are not migrated if you later change your TypeScript types.
Usage
Put
Encrypt and store a secret value:
const result = await secrets.put(ctx, {
namespace: "acme:production",
name: "stripe",
value: "sk_live_...",
metadata: { owner: "billing" },
ttlMs: 90 * 24 * 60 * 60 * 1000,
});
Notes:
ttlMsis converted to an absoluteexpiresAt- when overwriting and omitting
metadataorttlMs, existing metadata and expiry are preserved - use
update(..., { metadata: null, expiresAt: null })to clear optional fields
Get
Load and decrypt a secret:
const result = await secrets.get(ctx, {
namespace: "acme:production",
name: "stripe",
});
get returns:
{ ok: true, value, metadata, expiresAt, updatedAt }{ ok: false, reason: "not_found" | "expired" | "key_version_unavailable" }
key_version_unavailable means the row exists, but the runtime no longer has
the KEK version needed to unwrap it.
Has
Check whether a usable secret exists:
const exists = await secrets.has(ctx, {
namespace: "acme:production",
name: "stripe",
});
Expired secrets return false.
Update
Update plaintext metadata or expiry without rewriting the encrypted value:
await secrets.update(ctx, {
namespace: "acme:production",
name: "stripe",
metadata: { owner: "finance", label: "primary" },
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
});
Pass null to clear metadata or expiry entirely.
Remove
Delete a secret and append a matching audit event:
await secrets.remove(ctx, {
namespace: "acme:production",
name: "stripe",
});
List
Paginate secrets in one namespace:
const page = await secrets.list(ctx, {
namespace: "acme:production",
order: "desc",
paginationOpts: { numItems: 20, cursor: null },
});
Notes:
- rows are ordered by
updatedAt - expired rows are still listed
- each row includes
effectiveState: "active" | "expired"
List Events
Paginate audit events:
const events = await secrets.listEvents(ctx, {
namespace: "acme:production",
paginationOpts: { numItems: 50, cursor: null },
});
You can also filter by name, type, or by secretId.
V1 restrictions:
nameandtypecannot be combined- when filtering by
nameortype,namespaceis required secretIdcannot be combined withnamespace,name, ortype
Rotate Keys
Rewrap old rows from one key version to the active version:
const result = await secrets.rotateKeys(ctx, {
fromVersion: 1,
batchSize: 100,
cursor: null,
});
Result fields:
fromVersiontoVersionprocessedrotatedskippedisDonecontinueCursor
Important semantics:
- rotation rewraps DEKs only
- the encrypted secret plaintext is not rewritten
- concurrent writes are protected with compare-and-swap checks
- stale rows are skipped safely and can be retried in a later batch
- do not remove an old KEK until rotation is fully drained
Cleanup Expired Secrets
Hard-delete secrets that have remained expired past the retention window:
const result = await secrets.cleanupSecrets(ctx, {
retentionMs: 30 * 24 * 60 * 60 * 1000,
});
What cleanupSecrets does:
- deletes expired secret rows once they are older than the retention window
- writes a final
deletedaudit event withdeletedReason: "expired_cleanup" - processes up to 100 rows per run and automatically reschedules itself while backlog remains
Cleanup Audit Events
Hard-delete audit events older than the retention window:
const result = await secrets.cleanupEvents(ctx, {
retentionMs: 180 * 24 * 60 * 60 * 1000,
});
What cleanupEvents does:
- deletes audit rows independently from secret cleanup
- lets audit history outlive expired secrets when you want longer retention
- processes up to 100 rows per run and automatically reschedules itself while backlog remains
Recommended host-app pattern:
// convex/cleanup.ts
import { internalMutation } from "./_generated/server";
import { secrets } from "./secrets";
export const cleanupSecrets = internalMutation({
handler: (ctx) =>
secrets.cleanupSecrets(ctx, {
retentionMs: 30 * 24 * 60 * 60 * 1000,
}),
});
export const cleanupEvents = internalMutation({
handler: (ctx) =>
secrets.cleanupEvents(ctx, {
retentionMs: 180 * 24 * 60 * 60 * 1000,
}),
});
// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval(
"cleanup expired secrets",
{ hours: 24 },
internal.cleanup.cleanupSecrets,
);
crons.interval(
"cleanup secret audit events",
{ hours: 24 },
internal.cleanup.cleanupEvents,
);
export default crons;
Rotation Workflow
keys[0] is the active write key. Any remaining entries are older decrypt-only
keys.
keys: [
{ version: 3, value: process.env.SECRET_STORE_KEY_V3! }, // active
{ version: 2, value: process.env.SECRET_STORE_KEY_V2! }, // decrypt-only
{ version: 1, value: process.env.SECRET_STORE_KEY_V1! }, // decrypt-only
];
Recommended flow:
- prepend a new key version to
keys - deploy so new writes start using that version
- run rotation for each older version you want to drain
- keep old keys configured until those rows are fully rotated
- deploy again with drained old versions removed
Recommended host-app wrappers:
See example/convex/rotate.ts for a working
version of this pattern.
// convex/rotate.ts
import { v } from "convex/values";
import { internalAction, type ActionCtx } from "./_generated/server";
import { secrets } from "./secrets";
async function rotateVersion(ctx: ActionCtx, fromVersion: number) {
let cursor: string | null = null;
while (true) {
const result = await secrets.rotateKeys(ctx, {
fromVersion,
batchSize: 100,
cursor,
});
if (result.isDone) {
return result;
}
cursor = result.continueCursor ?? null;
}
}
export const rotateSecretStoreVersion = internalAction({
args: { fromVersion: v.number() },
handler: async (ctx, args) => {
return await rotateVersion(ctx, args.fromVersion);
},
});
export const rotateSecretStoreToLatest = internalAction({
handler: async (ctx) => {
for (const fromVersion of [2, 1]) {
await rotateVersion(ctx, fromVersion);
}
},
});
Optional temporary cron during a rotation window:
// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval(
"drain secret-store old keys",
{ minutes: 10 },
internal.rotate.rotateSecretStoreToLa
[README truncated for prompt length]