Skip to content

Find a component

Loading the directory…

↑ ↓ to select · Enter to open · Esc to closeBrowse all →
Contributor login

Convex Secret Store

Encrypted, namespace-scoped secret storage for Convex with key rotation, expiry, and audit logs.

Upstream source
git clone https://github.com/gaganref/convex-secret-store.git
cd convex-secret-store

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

Available to evaluate

Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.

Getting started

  1. Get the source at the revision above and read its package documentation.
  2. Check the required Bijection SDK version, dependencies, and external service credentials.
  3. Follow the package setup instructions and the guide to using components.
  4. 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:production vs acme: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.

agent-prompt.md
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 SecretStore class 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:

  1. the plaintext value is encrypted with a fresh DEK
  2. that DEK is wrapped with the active KEK
  3. Convex stores:
    • encryptedValue
    • iv
    • wrappedDEK
    • dekIv
    • keyVersion

When you call get:

  1. the row is loaded from Convex
  2. the stored keyVersion selects the correct KEK
  3. the DEK is unwrapped
  4. 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 — any string subtype. When set, namespace becomes 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:

  • ttlMs is converted to an absolute expiresAt
  • when overwriting and omitting metadata or ttlMs, 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:

  • name and type cannot be combined
  • when filtering by name or type, namespace is required
  • secretId cannot be combined with namespace, name, or type

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:

  • fromVersion
  • toVersion
  • processed
  • rotated
  • skipped
  • isDone
  • continueCursor

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 deleted audit event with deletedReason: "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:

  1. prepend a new key version to keys
  2. deploy so new writes start using that version
  3. run rotation for each older version you want to drain
  4. keep old keys configured until those rows are fully rotated
  5. 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]

Keywords