Protect.js
End-to-end field level encryption for JavaScript/TypeScript apps with zeroβknowledge key management. Search encrypted data without decrypting it.
β Please star this repo if you find it useful!
Protect.js lets you encrypt every value with its own keyβwithout sacrificing performance or usability. Encryption happens in your app; ciphertext is stored in your database.
Perβvalue unique keys are powered by CipherStash ZeroKMS bulk key operations, backed by a root key in AWS KMS.
Encrypted data is structured as an EQL JSON payload and can be stored in any database that supports JSONB.
[!IMPORTANT]
Searching, sorting, and filtering on encrypted data is currently only supported when storing encrypted data in PostgreSQL.
Read more about searching encrypted data.
Looking for DynamoDB support? Check out the Protect.js for DynamoDB helper library.
Quick start (60 seconds)
Create an account and workspace in the CipherStash dashboard, then follow the onboarding guide to generate your client credentials and store them in your .env file.
Install the package:
npm install @cipherstash/protect
Start encrypting data:
import { protect } from "@cipherstash/protect";
import { csTable, csColumn } from "@cipherstash/protect";
const users = csTable("users", { email: csColumn("email") });
const client = await protect({ schemas: [users] });
const encrypted = await client.encrypt("alice@example.com", {
table: users,
column: users.email,
});
if (encrypted.failure) {
}
const decrypted = await client.decrypt(encrypted.data);
Architecture (high level)

Table of contents
For more specific documentation, refer to the docs.
Features
Protect.js protects data in using industry-standard AES encryption.
Protect.js uses ZeroKMS for bulk encryption and decryption operations.
This enables every encrypted value, in every column, in every row in your database to have a unique key β without sacrificing performance.
Features:
- Bulk encryption and decryption: Protect.js uses ZeroKMS for encrypting and decrypting thousands of records at once, while using a unique key for every value.
- Single item encryption and decryption: Just looking for a way to encrypt and decrypt single values? Protect.js has you covered.
- Really fast: ZeroKMS's performance makes using millions of unique keys feasible and performant for real-world applications built with Protect.js.
- Identity-aware encryption: Lock down access to sensitive data by requiring a valid JWT to perform a decryption.
- Audit trail: Every decryption event will be logged in ZeroKMS to help you prove compliance.
- Searchable encryption: Protect.js supports searching encrypted data in PostgreSQL.
- TypeScript support: Strongly typed with TypeScript interfaces and types.
Use cases:
- Trusted data access: make sure only your end-users can access their sensitive data stored in your product.
- Meet compliance requirements faster: meet and exceed the data encryption requirements of SOC2 and ISO27001.
- Reduce the blast radius of data breaches: limit the impact of exploited vulnerabilities to only the data your end-users can decrypt.
Installing Protect.js
Install the @cipherstash/protect package with your package manager of choice:
npm install @cipherstash/protect
yarn add @cipherstash/protect
pnpm add @cipherstash/protect
[!TIP]
Bun is not currently supported due to a lack of Node-API compatibility. Under the hood, Protect.js uses CipherStash Client which is written in Rust and embedded using Neon.
Opt-out of bundling
[!IMPORTANT]
You need to opt-out of bundling when using Protect.js.
Protect.js uses Node.js specific features and requires the use of the native Node.js require.
When using Protect.js, you need to opt-out of bundling for tools like Webpack, esbuild, or Next.js.
Read more about building and bundling with Protect.js.
Getting started
Configuration
If you haven't already, sign up for a CipherStash account.
Once you have an account, you will create a Workspace which is scoped to your application environment.
Follow the onboarding steps to get your first set of credentials required to use Protect.js.
By the end of the onboarding, you will have the following environment variables:
CS_WORKSPACE_CRN=
CS_CLIENT_ID=
CS_CLIENT_KEY=
CS_CLIENT_ACCESS_KEY=
Save these environment variables to a .env file in your project.
Basic file structure
The following is the basic file structure of the project.
In the src/protect/ directory, we have the table definition in schema.ts and the protect client in index.ts.
π¦ <project root>
β π src
β β π protect
β β β π index.ts
β β β π schema.ts
β β π index.ts
β π .env
β π cipherstash.toml
β π cipherstash.secret.toml
β π package.json
β π tsconfig.json
Define your schema
Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt.
Define your tables and columns by adding this to src/protect/schema.ts:
import { csTable, csColumn } from "@cipherstash/protect";
export const users = csTable("users", {
email: csColumn("email"),
});
export const orders = csTable("orders", {
address: csColumn("address"),
});
Searchable encryption:
If you want to search encrypted data in your PostgreSQL database, you must declare the indexes in schema in src/protect/schema.ts:
import { csTable, csColumn } from "@cipherstash/protect";
export const users = csTable("users", {
email: csColumn("email").freeTextSearch().equality().orderAndRange(),
});
export const orders = csTable("orders", {
address: csColumn("address"),
});
Read more about defining your schema.
Initialize the Protect client
To import the protect function and initialize a client with your defined schema, add the following to src/protect/index.ts:
import { protect, type ProtectClientConfig } from "@cipherstash/protect";
import { users, orders } from "./schema";
const config: ProtectClientConfig = {
schemas: [users, orders],
}
export const protectClient = await protect(config);
The protect function requires at least one csTable be provided in the schemas array.
Encrypt data
Protect.js provides the encrypt function on protectClient to encrypt data.
encrypt takes a plaintext string, and an object with the table and column as parameters.
To start encrypting data, add the following to src/index.ts:
import { users } from "./protect/schema";
import { protectClient } from "./protect";
const encryptResult = await protectClient.encrypt("secret@squirrel.example", {
column: users.email,
table: users,
});
if (encryptResult.failure) {
console.log(
"error when encrypting:",
encryptResult.failure.type,
encryptResult.failure.message
);
}
console.log("EQL Payload containing ciphertexts:", encryptResult.data);
The encrypt function will return a Result object with either a data key, or a failure key.
The encryptResult will return one of the following:
{
data: EncryptedPayload
}
{
failure: {
type: 'EncryptionError',
message: 'A message about the error'
}
}
Decrypt data
Protect.js provides the decrypt function on protectClient to decrypt data.
decrypt takes an encrypted data object as a parameter.
To start decrypting data, add the following to src/index.ts:
import { protectClient } from "./protect";
const decryptResult = await protectClient.decrypt(encryptResult.data);
if (decryptResult.failure) {
console.log(
"error when decrypting:",
decryptResult.failure.type,
decryptResult.failure.message
);
}
const plaintext = decryptResult.data;
console.log("plaintext:", plaintext);
The decrypt function returns a Result object with either a data key, or a failure key.
The decryptResult will return one of the following:
{
data: 'secret@squirrel.example'
}
{
failure: {
type: 'DecryptionError',
message: 'A message about the error'
}
}
Working with models and objects
Protect.js provides model-level encryption methods that make it easy to encrypt and decrypt entire objects.
These methods automatically handle the encryption of fields defined in your schema.
If you are working with a large data set, the model operations are significantly faster than encrypting and decrypting individual objects as they are able to perform bulk operations.
[!TIP]
CipherStash ZeroKMS is optimized for bulk operations.
All the model operations are able to take advantage of this performance for real-world use cases by only making a single call to ZeroKMS regardless of the number of objects you are encrypting or decrypting while still using a unique key for each record.
Encrypting a model
Use the encryptModel method to encrypt a model's fields that are defined in your schema:
import { protectClient } from "./protect";
import { users } from "./protect/schema";
const user = {
id: "1",
email: "user@example.com",
address: "123 Main St",
createdAt: new Date("2024-01-01"),
};
const encryptedResult = await protectClient.encryptModel(user, users);
if (encryptedResult.failure) {
console.log(
"error when encrypting:",
encryptedResult.failure.type,
encryptedResult.failure.message
);
}
const encryptedUser = encryptedResult.data;
console.log("encrypted user:", encryptedUser);
The encryptModel function will only encrypt fields that are defined in your schema.
Other fields (like id and createdAt in the example above) will remain unchanged.
Type safety with models
Protect.js provides strong TypeScript support for model operations.
You can specify your model's type to ensure end-to-end type safety:
import { protectClient } from "./protect";
import { users } from "./protect/schema";
type User = {
id: string;
email: string | null;
address: string | null;
createdAt: Date;
updatedAt: Date;
metadata?: {
preferences?: {
notifications: boolean;
theme: string;
};
};
};
const encryptedResult = await protectClient.encryptModel<User>(user, users);
if (encryptedResult.failure) {
}
const encryptedUser = encryptedResult.data;
const decryptedResult = await protectClient.decryptModel<User>(encryptedUser);
if (decryptedResult.failure) {
}
const decryptedUser = decryptedResult.data;
const bulkEncryptedResult = await protectClient.bulkEncryptModels<User>(
userModels,
users
);
const bulkDecryptedResult = await protectClient.bulkDecryptModels<User>(
bulkEncryptedResult.data
);
The type system ensures that:
- Input models match your defined type structure
- Only fields defined in your schema are encrypted
- Encrypted and decrypted results maintain the correct type structure
- Optional and nullable fields are properly handled
- Nested object structures are preserved
- Additional properties not defined in the schema remain unchanged
This type safety helps catch potential issues at compile time and provides better IDE support with autocompletion and type hints.
[!TIP]
When using TypeScript with an ORM, you can reuse your ORM's model types directly with Protect.js's model operations.
Example with Drizzle infered types:
import { protectClient } from "./protect";
import { jsonb, pgTable, serial, InferSelectModel } from "drizzle-orm/pg-core";
import { csTable, csColumn } from "@cipherstash/protect";
const protectUsers = csTable("users", {
email: csColumn("email"),
});
const users = pgTable("users", {
id: serial("id").primaryKey(),
email: jsonb("email").notNull(),
});
type User = InferSelectModel<typeof users>;
const user = {
id: "1",
email: "user@example.com",
};
const encryptedResult = await protectClient.encryptModel<User>(
user,
protectUsers
);
Decrypting a model
Use the decryptModel method to decrypt a model's encrypted fields:
import { protectClient } from "./protect";
const decryptedResult = await protectClient.decryptModel(encryptedUser);
if (decryptedResult.failure) {
console.log(
"error when decrypting:",
decryptedResult.failure.type,
decryptedResult.failure.message
);
}
const decryptedUser = decryptedResult.data;
console.log("decrypted user:", decryptedUser);
Bulk model operations
For better performance when working with multiple models, use the bulkEncryptModels and bulkDecryptModels methods:
import { protectClient } from "./protect";
import { users } from "./protect/schema";
const userModels = [
{
id: "1",
email: "user1@example.com",
address: "123 Main St",
},
{
id: "2",
email: "user2@example.com",
address: "456 Oak Ave",
},
];
const encryptedResult = await protectClient.bulkEncryptModels(
userModels,
users
);
if (encryptedResult.failure) {
}
const encryptedUsers = encryptedResult.data;
const decryptedResult = await protectClient.bulkDecryptModels(encryptedUsers);
if (decryptedResult.failure) {
}
const decryptedUsers = decryptedResult.data;
The model encryption methods provide a higher-level interface that's particularly useful when working with ORMs or when you need to encrypt multiple fields in an object.
They automatically handle the mapping between your model's structure and the encrypted fields defined in your schema.
Bulk operations
Protect.js provides direct access to ZeroKMS bulk operations through the bulkEncrypt and bulkDecrypt methods. These methods are ideal when you need maximum performance and want to handle the correlation between encrypted/decrypted values and your application data manually.
[!TIP]
The bulk operations provide the most direct interface to ZeroKMS's blazing fast bulk encryption and decryption capabilities. Each value gets a unique key while maintaining optimal performance through a single call to ZeroKMS.
Bulk encryption
Use the bulkEncrypt method to encrypt multiple plaintext values at once:
import { protectClient } from "./protect";
import { users } from "./protect/schema";
const plaintexts = [
{ id: "user1", plaintext: "alice@example.com" },
{ id: "user2", plaintext: "bob@example.com" },
{ id: "user3", plaintext: "charlie@example.com" },
];
const encryptedResult = await protectClient.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
});
if (encryptedResult.failure) {
console.log(
"error when bulk encrypting:",
encryptedResult.failure.type,
encryptedResult.failure.message
);
}
const encryptedData = encryptedResult.data;
console.log("encrypted data:", encryptedData);
The bulkEncrypt method returns an array of objects with the following structure:
[
{ id: "user1", data: EncryptedPayload },
{ id: "user2", data: EncryptedPayload },
{ id: "user3", data: EncryptedPayload },
]
You can also encrypt without IDs if you don't need correlation:
const plaintexts = [
{ plaintext: "alice@example.com" },
{ plaintext: "bob@example.com" },
{ plaintext: "charlie@example.com" },
];
const encryptedResult = await protectClient.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
});
Bulk decryption
Use the bulkDecrypt method to decrypt multiple encrypted values at once:
import { protectClient } from "./protect";
const decryptedResult = await protectClient.bulkDecrypt(encryptedData);
if (decryptedResult.failure) {
console.log(
"error when bulk decrypting:",
decryptedResult.failure.type,
decryptedResult.failure.message
);
}
const decryptedData = decryptedResult.data;
console.log("decrypted data:", decryptedData);
The bulkDecrypt method returns an array of objects with the following structure:
[
{ id: "user1", data: "alice@example.com" },
{ id: "user2", data: "bob@example.com" },
{ id: "user3", data: "charlie@example.com" },
]
Response structure
The bulkDecrypt method returns a Result object that represents the overall operation status. When successful from an HTTP and execution perspective, the data field contains an array where each item can have one of two outcomes:
- Success: The item has a
data field containing the decrypted plaintext
- Failure: The item has an
error field containing a specific error message explaining why that particular decryption failed
{
data: [
{ id: "user1", data: "alice@example.com" },
{ id: "user2", error: "Invalid ciphertext format" },
{ id: "user3", data: "charlie@example.com" },
]
}
[!NOTE]
The underlying ZeroKMS response uses HTTP status code 207 (Multi-Status) to indicate that the bulk operation completed, but individual items within the batch may have succeeded or failed. This allows you to handle partial failures gracefully while still processing the successful decryptions.
You can handle mixed results by checking each item:
const decryptedResult = await protectClient.bulkDecrypt(encryptedData);
if (decryptedResult.failure) {
console.log("Bulk decryption failed:", decryptedResult.failure.message);
return;
}
decryptedResult.data.forEach((item) => {
if ('data' in item) {
console.log(`Decrypted ${item.id}:`, item.data);
} else if ('error' in item) {
console.log(`Failed to decrypt ${item.id}:`, item.error);
}
});
Handling null values
Bulk operations properly handle null values in both encryption and decryption:
const plaintexts = [
{ id: "user1", plaintext: "alice@example.com" },
{ id: "user2", plaintext: null },
{ id: "user3", plaintext: "charlie@example.com" },
];
const encryptedResult = await protectClient.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
});
const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data);
Using bulk operations with lock contexts
Bulk operations support identity-aware encryption through lock contexts:
import { LockContext } from "@cipherstash/protect/identify";
const lc = new LockContext();
const lockContext = await lc.identify(userJwt);
if (lockContext.failure) {
}
const plaintexts = [
{ id: "user1", plaintext: "alice@example.com" },
{ id: "user2", plaintext: "bob@example.com" },
];
const encryptedResult = await protectClient
.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
})
.withLockContext(lockContext.data);
const decryptedResult = await protectClient
.bulkDecrypt(encryptedResult.data)
.withLockContext(lockContext.data);
Performance considerations
Bulk operations are optimized for performance and can handle thousands of values efficiently:
const plaintexts = Array.from({ length: 1000 }, (_, i) => ({
id: `user${i}`,
plaintext: `user${i}@example.com`,
}));
const encryptedResult = await protectClient.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
});
const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data);
The bulk operations maintain the same security guarantees as individual operations - each value gets a unique key - while providing optimal performance through ZeroKMS's bulk processing capabilities.
Store encrypted data in a database
Encrypted data can be stored in any database that supports JSONB, noting that searchable encryption is only supported in PostgreSQL at the moment.
To store the encrypted data, specify the column type as jsonb.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email jsonb NOT NULL,
);
Searchable encryption in PostgreSQL
To enable searchable encryption in PostgreSQL, install the EQL custom types and functions.
-
Download the latest EQL install script:
curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql
Using Supabase? We ship an EQL release specifically for Supabase.
Download the latest EQL install script:
curl -sLo cipherstash-encrypt-supabase.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt-supabase.sql
-
Run this command to install the custom types and functions:
psql -f cipherstash-encrypt.sql
or with Supabase:
psql -f cipherstash-encrypt-supabase.sql
EQL is now installed in your database and you can enable searchable encryption by adding the eql_v2_encrypted type to a column.
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email eql_v2_encrypted
);
[!WARNING]
The eql_v2_encrypted type is a composite type and each ORM/client has a different way of handling inserts and selects.
We've documented how to handle inserts and selects for the different ORMs/clients in the docs.
Read more about how to search encrypted data in the docs.
Identity-aware encryption
[!IMPORTANT]
Right now identity-aware encryption is only supported if you are using Clerk as your identity provider.
Read more about lock contexts with Clerk and Next.js.
Protect.js can add an additional layer of protection to your data by requiring a valid JWT to perform a decryption.
This ensures that only the user who encrypted data is able to decrypt it.
Protect.js does this through a mechanism called a lock context.
Lock context
Lock contexts ensure that only specific users can access sensitive data.
[!CAUTION]
You must use the same lock context to encrypt and decrypt data.
If you use different lock contexts, you will be unable to decrypt the data.
To use a lock context, initialize a LockContext object with the identity claims.
import { LockContext } from "@cipherstash/protect/identify";
const lc = new LockContext();
[!NOTE]
When initializing a LockContext, the default context is set to use the sub Identity Claim.
Identifying a user for a lock context
A lock context needs to be locked to a user.
To identify the user, call the identify method on the lock context object, and pass a valid JWT from a user's session:
const identifyResult = await lc.identify(jwt);
if (identifyResult.failure) {
}
const lockContext = identifyResult.data;
Encrypting data with a lock context
To encrypt data with a lock context, call the optional withLockContext method on the encrypt function and pass the lock context object as a parameter:
import { protectClient } from "./protect";
import { users } from "./protect/schema";
const encryptResult = await protectClient
.encrypt("plaintext", {
table: users,
column: users.email,
})
.withLockContext(lockContext);
if (encryptResult.failure) {
}
console.log("EQL Payload containing ciphertexts:", encryptResult.data);
Decrypting data with a lock context
To decrypt data with a lock context, call the optional withLockContext method on the decrypt function and pass the lock context object as a parameter:
import { protectClient } from "./protect";
const decryptResult = await protectClient
.decrypt(encryptResult.data)
.withLockContext(lockContext);
if (decryptResult.failure) {
}
const plaintext = decryptResult.data;
Model encryption with lock context
All model operations support lock contexts for identity-aware encryption:
import { protectClient } from "./protect";
import { users } from "./protect/schema";
const myUsers = [
{
id: "1",
email: "user@example.com",
address: "123 Main St",
createdAt: new Date("2024-01-01"),
},
{
id: "2",
email: "user2@example.com",
address: "456 Oak Ave",
},
];
const encryptedResult = await protectClient
.encryptModel(myUsers[0], users)
.withLockContext(lockContext);
if (encryptedResult.failure) {
}
const decryptedResult = await protectClient
.decryptModel(encryptedResult.data)
.withLockContext(lockContext);
const bulkEncryptedResult = await protectClient
.bulkEncryptModels(myUsers, users)
.withLockContext(lockContext);
const bulkDecryptedResult = await protectClient
.bulkDecryptModels(bulkEncryptedResult.data)
.withLockContext(lockContext);
Supported data types
Protect.js currently supports encrypting and decrypting text.
Other data types like booleans, dates, ints, floats, and JSON are well-supported in other CipherStash products, and will be coming to Protect.js soon.
Until support for other data types are available, you can express interest in this feature by adding a :+1: on this GitHub Issue.
Searchable encryption
Read more about searching encrypted data in the docs.
Multi-tenant encryption
Protect.js supports multi-tenant encryption by using keysets.
Each keyset is cryptographically isolated from other keysets which esentially means that each tenant has their own unique keyspace.
If you are using a multi-tenant application, you can use keysets to encrypt data for each tenant creating a strong security boundary.
In the CipherStash Dashboard, you can create and manage keysets and then use the keyset identifier to encrypt data for each tenant when initializing the Protect.js client.
import { protect } from "@cipherstash/protect";
import { users } from "./protect/schema";
const protectClient = await protect({
schemas: [users],
keyset: {
id: '123e4567-e89b-12d3-a456-426614174000'
},
})
const protectClient = await protect({
schemas: [users],
keyset: {
name: 'Company A'
},
})
[!IMPORTANT]
When creating a new keyset, make sure to grant your client access to the keyset or client initialization will fail.
Read more about managing keyset access.
Logging
[!TIP]
@cipherstash/protect will NEVER log plaintext data.
This is by design to prevent sensitive data from leaking into logs.
@cipherstash/protect and @cipherstash/nextjs will log to the console with a log level of info by default.
To enable the logger, configure the following environment variable:
PROTECT_LOG_LEVEL=debug
PROTECT_LOG_LEVEL=info
PROTECT_LOG_LEVEL=error
CipherStash Client
Protect.js is built on top of the CipherStash Client Rust SDK which is embedded with the @cipherstash/protect-ffi package.
The @cipherstash/protect-ffi source code is available on GitHub.
Read more about configuring the CipherStash Client in the configuration docs.
Example applications
Looking for examples of how to use Protect.js?
Check out the example applications:
@cipherstash/protect can be used with most ORMs.
If you're interested in using @cipherstash/protect with a specific ORM, please create an issue.
Builds and bundling
@cipherstash/protect is a native Node.js module, and relies on native Node.js require to load the package.
Here are a few resources to help based on your tool set:
[!TIP]
Deploying to Linux (e.g., AWS Lambda) with npm lockfile v3 and seeing runtime module load errors? See the troubleshooting guide: docs/how-to/npm-lockfile-v3.
Contributing
Please read the contribution guide.
License
Protect.js is MIT licensed.
Didn't find what you wanted?
Click here to let us know what was missing from our docs.