This code has not been tested or verified to be cryptographically secure. It is meant as a demonstration of an architecture to provide limited protection against accidental secret release.
Type-safe encrypted PII field storage for Convex. Store sensitive data (SSN, credit cards, etc.) with per-user encryption keys and full TypeScript safety.
- Type-safe - Encrypted fields are objects, not strings. TypeScript prevents accidental usage without decryption.
- Per-user encryption keys - Each user gets their own Key Encryption Key (KEK)
- Per-field encryption - Each value has its own Data Encryption Key (DEK)
- AES-256-GCM - Industry-standard authenticated encryption
- Fast - Encryption/decryption happens in your code, not across isolate boundaries
- GDPR compliant - Easy deletion of all user data
npm install @convex-dev/encrypted-piiOr install from a local tarball:
npm install /path/to/convex-dev-encrypted-pii-0.1.0.tgzCreate or update your convex/convex.config.ts:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import encryptedPii from "@convex-dev/encrypted-pii/convex.config";
const app = defineApp();
app.use(encryptedPii);
export default app;Use the piiField() validator for encrypted fields:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { piiField } from "@convex-dev/encrypted-pii";
export default defineSchema({
users: defineTable({
// Regular fields
name: v.string(),
email: v.string(),
// Encrypted PII fields - use piiField() validator
ssn: v.optional(piiField()),
creditCard: v.optional(piiField()),
bankAccount: v.optional(piiField()),
}),
});Important: The piiField() validator creates an EncryptedField type (an object), not a string. This provides type safety.
Create a helper file to instantiate the client:
// convex/pii.ts
import { EncryptedPII } from "@convex-dev/encrypted-pii";
import { components } from "./_generated/api";
export const encryptedPii = new EncryptedPII(components.encryptedPii);Push your schema and component:
npx convex devBy default, encryption is always enabled. For easier debugging in development, you can disable encryption while keeping the same data shape:
// convex/pii.ts
import { EncryptedPII } from "@convex-dev/encrypted-pii";
import { components } from "./_generated/api";
// Option 1: Environment variable (ENCRYPT_PII=false to disable)
const encryptedPii = new EncryptedPII(components.encryptedPii, {
encryptionEnabled: process.env.ENCRYPT_PII !== "false",
});
// Option 2: Based on deployment URL
const isProd = process.env.CONVEX_CLOUD_URL?.includes(".convex.cloud");
const encryptedPii = new EncryptedPII(components.encryptedPii, {
encryptionEnabled: isProd,
});When encryptionEnabled: false:
- PII fields are stored in the same
EncryptedFieldobject shape - Instead of encrypted ciphertext, the
cfield contains plaintext - A sentinel marker
DEVELOPMENT_MODE_NOT_ENCRYPTEDin thekfield indicates dev mode - Reading works transparently - dev data is detected and returned as-is
Dev mode data:
{
"__encrypted": true,
"v": 1,
"c": "123-45-6789",
"i": "",
"k": "DEVELOPMENT_MODE_NOT_ENCRYPTED"
}Prod mode data:
{
"__encrypted": true,
"v": 1,
"c": "base64-ciphertext...",
"i": "base64-iv...",
"k": "encryptedDek:dekIv"
}Write protection: When encryption is disabled, wrapDb() checks if any user encryption keys exist in the database. If keys are found, it throws an error to prevent accidentally writing plaintext to a production database.
Error: Cannot use wrapDb with encryption disabled: user encryption keys exist in the database.
This is a safety check to prevent writing plaintext to a production database.
Either enable encryption or use a fresh dev database.
Read protection: When encryption is disabled but real encrypted data is encountered (e.g., prod data copied to dev), an error is thrown:
Error: Cannot decrypt field "ssn": found encrypted data but encryption is disabled.
This may happen if prod data was copied to a dev environment.
- Dev data with prod mode: Works - encrypted data is detected and decrypted normally
- Prod data with dev mode: Throws error - cannot decrypt without encryption enabled
// convex/users.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { encryptedPii } from "./pii";
export const storeSSN = mutation({
args: {
userId: v.id("users"),
ssn: v.string()
},
handler: async (ctx, args) => {
// Step 1: Get PII helper for this user (fetches their encryption key once)
const pii = await encryptedPii.forUser(ctx, args.userId);
// Step 2: Encrypt the value
const encryptedSSN = await pii.encrypt(args.ssn);
// Step 3: Store directly in your document
await ctx.db.patch(args.userId, {
ssn: encryptedSSN,
});
},
});export const getSSN = mutation({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
// Step 1: Get PII helper for this user
const pii = await encryptedPii.forUser(ctx, args.userId);
// Step 2: Fetch the document
const user = await ctx.db.get(args.userId);
if (!user) return null;
// Step 3: Decrypt the field
const ssn = await pii.decrypt(user.ssn);
return { ssn };
},
});Use forUserQuery() to decrypt in queries. Returns null if the user has no encryption key yet.
export const getSSN = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
// forUserQuery returns null if user has no key yet
const pii = await encryptedPii.forUserQuery(ctx, args.userId);
if (!pii) return null;
const user = await ctx.db.get(args.userId);
if (!user) return null;
const ssn = await pii.decrypt(user.ssn);
return { ssn };
},
});For cleaner code without manual encrypt()/decrypt() calls, use the wrapped database API:
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { encryptedPii } from "./pii";
import schema from "./schema";
export const storeUser = mutation({
args: { name: v.string(), ssn: v.string() },
handler: async (ctx, args) => {
// Get wrapped db - pass your schema so it knows which fields are PII
const db = await encryptedPii.wrapDb(ctx, args.name, schema);
// Just write plain strings - encryption happens automatically!
return await db.insert("users", {
name: args.name,
ssn: args.ssn, // Encrypted automatically based on piiField() in schema
});
},
});
export const getUser = mutation({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const db = await encryptedPii.wrapDb(ctx, args.userId, schema);
// Returns decrypted data automatically
const user = await db.get(args.userId);
// user.ssn is already a string, not EncryptedField!
return user;
},
});
// For queries (read-only), use wrapDbQuery
export const getUserQuery = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const db = await encryptedPii.wrapDbQuery(ctx, args.userId, schema);
if (!db) return null; // User has no encryption key yet
return await db.get(args.userId);
},
});The wrapped db supports all common operations:
const db = await encryptedPii.wrapDb(ctx, userId, schema);
// Write operations - PII fields auto-encrypted
await db.insert("users", { name: "John", ssn: "123-45-6789" });
await db.patch(userId, { ssn: "987-65-4321" });
await db.replace(userId, { name: "Jane", ssn: "111-22-3333" });
await db.delete(userId);
// Read operations - PII fields auto-decrypted
const user = await db.get(userId);
const users = await db.query("users").collect();
const firstUser = await db.query("users").first();
const uniqueUser = await db.query("users")
.withIndex("by_email", q => q.eq("email", "john@example.com"))
.unique();export const storeAllPII = mutation({
args: {
userId: v.id("users"),
ssn: v.string(),
creditCard: v.string(),
},
handler: async (ctx, args) => {
const pii = await encryptedPii.forUser(ctx, args.userId);
// Encrypt multiple fields
await ctx.db.patch(args.userId, {
ssn: await pii.encrypt(args.ssn),
creditCard: await pii.encrypt(args.creditCard),
});
},
});Use decryptMany() for convenience:
export const getAllPII = mutation({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const pii = await encryptedPii.forUser(ctx, args.userId);
const user = await ctx.db.get(args.userId);
if (!user) return null;
// Decrypt multiple fields at once
const decrypted = await pii.decryptMany({
ssn: user.ssn,
creditCard: user.creditCard,
});
return {
ssn: decrypted.ssn, // string | null
creditCard: decrypted.creditCard, // string | null
};
},
});export const createUser = mutation({
args: {
name: v.string(),
email: v.string(),
ssn: v.string(),
},
handler: async (ctx, args) => {
// First create the user without PII
const userId = await ctx.db.insert("users", {
name: args.name,
email: args.email,
});
// Then encrypt and add PII
const pii = await encryptedPii.forUser(ctx, userId);
await ctx.db.patch(userId, {
ssn: await pii.encrypt(args.ssn),
});
return userId;
},
});export const deleteUserPII = mutation({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
// Step 1: Clear PII fields from your document
await ctx.db.patch(args.userId, {
ssn: undefined,
creditCard: undefined,
bankAccount: undefined,
});
// Step 2: Delete the user's encryption key from the component
// This ensures their key can never be used again
await encryptedPii.deleteAllUserData(ctx, args.userId);
},
});export const hasPII = mutation({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
return {
hasSSN: user?.ssn !== undefined,
hasCreditCard: user?.creditCard !== undefined,
};
},
});The piiField() validator creates an EncryptedField type that TypeScript won't let you use as a string:
const user = await ctx.db.get(userId);
// ❌ Type error - EncryptedField is not assignable to string
console.log(`SSN: ${user.ssn}`);
sendEmail(user.ssn);
JSON.stringify({ ssn: user.ssn }); // Works but exposes encrypted blob
// ✅ Correct - must decrypt first
const pii = await encryptedPii.forUser(ctx, userId);
const ssn = await pii.decrypt(user.ssn);
console.log(`SSN: ${ssn}`);type EncryptedField = {
__encrypted: true; // Marker for identification
v: number; // Version (for future migrations)
c: string; // Ciphertext (base64)
i: string; // IV (base64)
k: string; // Encrypted DEK (base64)
};Create a new EncryptedPII client.
component- The encrypted PII component fromcomponents.encryptedPiioptions.encryptionEnabled- Whether to encrypt PII fields (default:true). Set tofalsefor dev mode.
import { EncryptedPII } from "@convex-dev/encrypted-pii";
import { components } from "./_generated/api";
// Production mode (default - encryption enabled)
const encryptedPii = new EncryptedPII(components.encryptedPii);
// Dev mode (plaintext stored in encrypted shape)
const encryptedPii = new EncryptedPII(components.encryptedPii, {
encryptionEnabled: false,
});Get a PII helper for a specific user (for mutations). Creates the user's encryption key if it doesn't exist yet.
ctx- Convex mutation contextownerId- String identifying the user (typicallyctx.auth.getUserIdentity().subjector a user document ID)
const pii = await encryptedPii.forUser(ctx, userId);Get a PII helper for a specific user (for queries - read-only). Returns null if the user has no encryption key yet.
Use this in queries when you only need to decrypt existing data. The user's key must have been created by a prior forUser() call in a mutation.
ctx- Convex query contextownerId- String identifying the user
const pii = await encryptedPii.forUserQuery(ctx, userId);
if (!pii) return null; // User has no encrypted data yetGet a wrapped database that automatically encrypts/decrypts PII fields. Use in mutations.
ctx- Convex mutation contextownerId- String identifying the userschema- Your Convex schema (import from./schema)
import schema from "./schema";
const db = await encryptedPii.wrapDb(ctx, userId, schema);
await db.patch(userId, { ssn: "123-45-6789" }); // Auto-encrypted
const user = await db.get(userId); // Auto-decryptedGet a wrapped database for queries (read-only). Returns null if the user has no encryption key yet.
ctx- Convex query contextownerId- String identifying the userschema- Your Convex schema
const db = await encryptedPii.wrapDbQuery(ctx, userId, schema);
if (!db) return null;
const user = await db.get(userId); // Auto-decryptedDelete all encryption keys for a user. Call this for GDPR compliance.
const keysDeleted = await encryptedPii.deleteAllUserData(ctx, userId);Returned by encryptedPii.forUser(). All methods run locally (no isolate boundary crossing).
Encrypt a string value. Returns an EncryptedField object to store in your document.
const encrypted = await pii.encrypt("123-45-6789");
await ctx.db.patch(userId, { ssn: encrypted });Decrypt an encrypted field. Returns null if the field is null/undefined.
const ssn = await pii.decrypt(user.ssn);Decrypt multiple fields at once. Returns an object with the same keys.
const { ssn, creditCard } = await pii.decryptMany({
ssn: user.ssn,
creditCard: user.creditCard,
});Returned by encryptedPii.wrapDb() or encryptedPii.wrapDbQuery(). Provides automatic encryption/decryption.
All write methods automatically encrypt PII fields (identified by piiField() in your schema):
await db.insert("users", { name: "John", ssn: "123-45-6789" });
await db.patch(userId, { ssn: "987-65-4321" });
await db.replace(userId, { name: "Jane", ssn: "111-22-3333" });
await db.delete(userId);All read methods automatically decrypt fields with the __encrypted marker:
// Get by ID
const user = await db.get(userId);
// Query with full builder chain support
const users = await db.query("users")
.withIndex("by_email", q => q.eq("email", "john@example.com"))
.filter(q => q.neq(q.field("name"), "Admin"))
.order("desc")
.take(10)
.collect();
const first = await db.query("users").first();
const unique = await db.query("users").withIndex("by_email", ...).unique();Utility type that transforms EncryptedField properties to string. Use for better TypeScript support with wrapped db results:
import type { Doc } from "./_generated/dataModel";
import type { Decrypted } from "@convex-dev/encrypted-pii";
// Original type has ssn: EncryptedField
type User = Doc<"users">;
// Decrypted type has ssn: string
type DecryptedUser = Decrypted<Doc<"users">>;
// Use with wrapped db for full type safety
const user = await db.get(userId) as DecryptedUser;
console.log(user.ssn.toUpperCase()); // TypeScript knows ssn is stringConvex validator for encrypted PII fields. Use in your schema.
import { piiField } from "@convex-dev/encrypted-pii";
// In schema:
ssn: v.optional(piiField()),Encrypted fields are stored as objects in your Convex documents:
{
"_id": "jh7abc123...",
"_creationTime": 1234567890,
"name": "John Doe",
"email": "john@example.com",
"ssn": {
"__encrypted": true,
"v": 1,
"c": "base64-encoded-ciphertext...",
"i": "base64-encoded-iv...",
"k": "base64-encrypted-dek:base64-dek-iv"
}
}The __encrypted: true marker makes it obvious in the Convex dashboard that data is encrypted.
Master Key (1 per component instance)
│
└── User KEK (1 per user)
│
└── Field DEK (1 per encrypted value)
│
└── Encrypted Value (AES-256-GCM)
-
Master Key: Generated once when the component is first used. Stored in the component's isolated tables. Encrypts all user KEKs.
-
User KEK (Key Encryption Key): Generated when
forUser()is first called for a user. Encrypted with the master key before storage. Used to encrypt/decrypt that user's field DEKs. -
Field DEK (Data Encryption Key): Generated fresh for each
encrypt()call. Encrypted with the user's KEK. Stored alongside the ciphertext in theEncryptedFieldobject. -
Encryption: AES-256-GCM with random 96-bit IVs. Provides both confidentiality and authenticity.
Encrypting:
plaintext
→ generate random DEK
→ encrypt plaintext with DEK
→ encrypt DEK with user's KEK
→ return EncryptedField object
Decrypting:
EncryptedField
→ decrypt DEK using user's KEK
→ decrypt ciphertext using DEK
→ return plaintext
The forUser() API is optimized for performance:
- One key fetch -
forUser()fetches the user's KEK once from the component (one isolate boundary crossing) - Local crypto - All
encrypt()/decrypt()calls use Web Crypto API locally - No more boundary crossings - After getting the key, everything runs in your code
This is significantly faster than an API that crosses the isolate boundary for every encrypt/decrypt operation.
- Encryption at rest with AES-256-GCM
- Per-user key isolation
- Per-field unique encryption keys
- Envelope encryption (keys encrypting keys)
- Zero-knowledge encryption - The server (Convex) can theoretically access the master key since it's stored in the component's tables. A malicious operator could decrypt data.
- Client-side encryption - Keys are managed server-side. For true zero-knowledge, you'd need keys derived from user passwords that never leave the client.
This component protects against:
- Accidental exposure of PII in logs/dashboards
- Database dumps containing plaintext PII
- Developers accidentally accessing raw PII
This component does NOT protect against:
- Malicious Convex operators
- Compromised server-side code
- Someone with full database access who also accesses the component's tables
If you have existing unencrypted PII data in your database, you'll need to migrate it to the encrypted format.
Add the encrypted field alongside your existing plaintext field:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { piiField } from "@convex-dev/encrypted-pii";
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
// Keep old field during migration
ssn: v.optional(v.string()),
// Add new encrypted field
ssnEncrypted: v.optional(piiField()),
}),
});// convex/migrations.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { encryptedPii } from "./pii";
// Migrate a single user
export const migrateUserPII = mutation({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user?.ssn || user.ssnEncrypted) {
return { status: "skipped" };
}
// Encrypt the plaintext value
const pii = await encryptedPii.forUser(ctx, args.userId);
await ctx.db.patch(args.userId, {
ssnEncrypted: await pii.encrypt(user.ssn),
ssn: undefined, // Clear plaintext
});
return { status: "migrated" };
},
});
// Migrate all users in batches
export const migrateAllUsers = mutation({
args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) },
handler: async (ctx, args) => {
const batchSize = args.batchSize ?? 100;
const results = await ctx.db
.query("users")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
let migrated = 0;
for (const user of results.page) {
if (user.ssn && !user.ssnEncrypted) {
const pii = await encryptedPii.forUser(ctx, user._id);
await ctx.db.patch(user._id, {
ssnEncrypted: await pii.encrypt(user.ssn),
ssn: undefined,
});
migrated++;
}
}
return {
migrated,
isDone: results.isDone,
continueCursor: results.continueCursor,
};
},
});Call migrateAllUsers repeatedly until isDone is true:
// From your app or dashboard
let cursor = undefined;
let totalMigrated = 0;
while (true) {
const result = await migrateAllUsers({ cursor });
totalMigrated += result.migrated;
if (result.isDone) break;
cursor = result.continueCursor;
}
console.log(`Migrated ${totalMigrated} users`);Once migration is complete:
- Rename the field in your schema:
users: defineTable({
name: v.string(),
email: v.string(),
ssn: v.optional(piiField()), // Renamed from ssnEncrypted
}),-
Update all code to use the new field name and encryption APIs.
-
Deploy and verify everything works.
Make sure you've installed the package and run npx convex dev to generate types.
Make sure you're importing from the correct location:
import { piiField } from "@convex-dev/encrypted-pii";forUser()requires a mutation context because it may create the user's encryption key on first use. Use this when encrypting data or when you need to ensure the key exists.forUserQuery()works in query context but returnsnullif the user has no key yet. Use this for read-only decryption when you know the user already has encrypted data.
MIT