npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

convex-secret-store

v0.2.0

Published

A secret store component for Convex.

Readme

Convex Secret Store

npm version Convex Component

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) {
  // loaded.value -> plaintext
}

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:

// convex/convex.config.ts
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   # 32 bytes, canonical base64
npx convex env set MY_APP_KEK_V1 "<paste the value>"

Construct the typed facade once and re-export it:

// convex/secrets.ts
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 }
//   | { ok: false, reason: "not_found" | "expired" | "key_unavailable" }

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 }
// throws ConvexError with code "not_found" | "expired" | "key_unavailable"

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,
  // one of:
  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);
// → { rotated, skipped, isDone }

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);
// → boolean

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? });   // default 30 days
await secrets.cleanupEvents(ctx, { retentionMs? });    // default 180 days

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.

  1. 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
  2. Trigger rotation. Call secrets.rotate(ctx) once (from the dashboard, a one-shot internal mutation, or a temporary cron during the drain window).
  3. Verify drain. Poll secrets.isRotationComplete(ctx) — when true, no secret is still on an old key.
  4. 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:

// convex/cleanup.ts
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),
});
// convex/crons.ts
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:

// convex/crons.ts (in addition to the cleanup crons above)
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":     /* deployment misconfigured */ break;
      case "key_unavailable":  /* row references a retired KEK */ break;
      case "decryption_failed":/* tampering or corruption */ break;
      case "not_found":        /* getOrThrow on a missing secret */ break;
      case "expired":          /* getOrThrow on an expired secret */ 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          # backend + frontend together
npm run build        # build the published package
npm run typecheck
npm run lint
npm test

Architectural decisions are recorded in docs/adr/. Domain terminology is in CONTEXT.md.