CipherStash Stack - Encryption
Comprehensive guide for implementing field-level encryption with
. Every value is encrypted with its own unique key via ZeroKMS (backed by AWS KMS). Encryption happens client-side before data leaves the application.
When to Use This Skill
- Adding field-level encryption to a TypeScript/Node.js project
- Defining encrypted table schemas
- Encrypting and decrypting individual values or entire models
- Implementing searchable encryption (equality, free-text, range, JSON queries)
- Bulk encrypting/decrypting large datasets
- Implementing identity-aware encryption with JWT-based lock contexts
- Setting up multi-tenant encryption with keysets
- Migrating from to
Installation
bash
npm install @cipherstash/stack
The package includes a native FFI module (
). You must opt out of bundling it in tools like Webpack, esbuild, or Next.js (
).
Configuration
Environment Variables
Set these in
or your hosting platform:
bash
CS_WORKSPACE_CRN=crn:ap-southeast-2.aws:your-workspace-id
CS_CLIENT_ID=your-client-id
CS_CLIENT_KEY=your-client-key
CS_CLIENT_ACCESS_KEY=your-access-key
Sign up at
cipherstash.com/signup to generate credentials.
Programmatic Config
typescript
const client = await Encryption({
schemas: [users],
config: {
workspaceCrn: "crn:ap-southeast-2.aws:your-workspace-id",
clientId: "your-client-id",
clientKey: "your-client-key",
accessKey: "your-access-key",
keyset: { name: "my-keyset" }, // optional: multi-tenant isolation
},
})
If
is omitted, the client reads
environment variables automatically.
Logging
Logging is disabled by default. Enable it via environment variable or programmatically:
bash
STASH_LOG_LEVEL=debug # debug | info | warn | error (enables logging automatically)
Programmatic Logging Configuration
typescript
const client = await Encryption({
schemas: [users],
logging: {
enabled: true, // toggle logging on/off (default: false, auto-enabled by STASH_LOG_LEVEL)
pretty: true, // pretty-print in development (default: auto-detected)
},
})
Log Draining
Send structured logs to an external observability platform:
typescript
const client = await Encryption({
schemas: [users],
logging: {
drain: (ctx) => {
// Forward to Axiom, Datadog, OTLP, etc.
fetch("https://your-service.com/logs", {
method: "POST",
body: JSON.stringify(ctx.event),
})
},
},
})
The SDK never logs plaintext data.
Subpath Exports
| Import Path | Provides |
|---|
| function (main entry point) |
@cipherstash/stack/schema
| , , , schema types |
@cipherstash/stack/identity
| class and identity types |
@cipherstash/stack/secrets
| class and secrets types |
@cipherstash/stack/drizzle
| , , createEncryptionOperators
for Drizzle ORM |
@cipherstash/stack/supabase
| wrapper for Supabase |
@cipherstash/stack/dynamodb
| helper for DynamoDB |
@cipherstash/stack/client
| Client-safe exports (schema builders + types only, no native FFI) |
| All TypeScript types |
Schema Definition
Define which tables and columns to encrypt using
and
:
typescript
import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema"
const users = encryptedTable("users", {
email: encryptedColumn("email")
.equality() // exact-match queries
.freeTextSearch() // full-text / fuzzy search
.orderAndRange(), // sorting and range queries
age: encryptedColumn("age")
.dataType("number")
.equality()
.orderAndRange(),
address: encryptedColumn("address"), // encrypt-only, no search indexes
})
const documents = encryptedTable("documents", {
metadata: encryptedColumn("metadata")
.searchableJson(), // encrypted JSONB queries (JSONPath + containment)
})
Index Types
| Method | Purpose | Query Type |
|---|
| Exact match lookups | |
| Full-text / fuzzy search | |
| Sorting, comparison, range queries | |
| Encrypted JSONB path and containment queries (auto-sets to ) | |
| Set plaintext data type | N/A |
Supported data types: (default),
,
,
,
,
Methods are chainable - call as many as you need on a single column.
Free-Text Search Options
typescript
encryptedColumn("bio").freeTextSearch({
tokenizer: { kind: "ngram", token_length: 3 }, // or { kind: "standard" }
token_filters: [{ kind: "downcase" }],
k: 6,
m: 2048,
include_original: false,
})
Type Inference
typescript
import type { InferPlaintext, InferEncrypted } from "@cipherstash/stack/schema"
type UserPlaintext = InferPlaintext<typeof users>
// { email: string; age: string; address: string }
type UserEncrypted = InferEncrypted<typeof users>
// { email: Encrypted; age: Encrypted; address: Encrypted }
Client Initialization
typescript
import { Encryption } from "@cipherstash/stack"
const client = await Encryption({ schemas: [users, documents] })
The
function returns
Promise<EncryptionClient>
and throws on error (e.g., bad credentials, missing config, invalid keyset UUID). At least one schema is required.
typescript
// Error handling
try {
const client = await Encryption({ schemas: [users] })
} catch (error) {
console.error("Init failed:", error.message)
}
Encrypt and Decrypt Single Values
typescript
// Encrypt
const encrypted = await client.encrypt("hello@example.com", {
column: users.email,
table: users,
})
if (encrypted.failure) {
console.error(encrypted.failure.message)
} else {
console.log(encrypted.data) // Encrypted payload (opaque object)
}
// Decrypt
const decrypted = await client.decrypt(encrypted.data)
if (!decrypted.failure) {
console.log(decrypted.data) // "hello@example.com"
}
Null values are preserved: encrypting
returns
.
Model Operations
Encrypt or decrypt an entire object. Only fields matching your schema are encrypted; other fields pass through unchanged.
The return type is
schema-aware: fields matching the table schema are typed as
, while other fields retain their original types. For best results, let TypeScript infer the type parameters from the arguments rather than providing an explicit
.
typescript
type User = { id: string; email: string; createdAt: Date }
const user = {
id: "user_123",
email: "alice@example.com", // defined in schema -> encrypted
createdAt: new Date(), // not in schema -> unchanged
}
// Encrypt model — let TypeScript infer the return type from the schema
const encResult = await client.encryptModel(user, users)
if (!encResult.failure) {
// encResult.data.email is typed as Encrypted
// encResult.data.id is typed as string
// encResult.data.createdAt is typed as Date
}
// Decrypt model
const decResult = await client.decryptModel(encResult.data)
if (!decResult.failure) {
console.log(decResult.data.email) // "alice@example.com"
}
The
type maps encrypted fields back to their plaintext types.
Passing an explicit type parameter (e.g.,
client.encryptModel<User>(...)
) still works for backward compatibility — the return type degrades to
in that case.
Bulk Operations
All bulk methods make a single call to ZeroKMS regardless of record count, while still using a unique key per value.
Bulk Encrypt / Decrypt (Raw Values)
typescript
const plaintexts = [
{ id: "u1", plaintext: "alice@example.com" },
{ id: "u2", plaintext: "bob@example.com" },
{ id: "u3", plaintext: null }, // null values preserved
]
const encrypted = await client.bulkEncrypt(plaintexts, {
column: users.email,
table: users,
})
// encrypted.data = [{ id: "u1", data: EncryptedPayload }, ...]
const decrypted = await client.bulkDecrypt(encrypted.data)
// Per-item error handling:
for (const item of decrypted.data) {
if ("data" in item) {
console.log(`${item.id}: ${item.data}`)
} else {
console.error(`${item.id} failed: ${item.error}`)
}
}
Bulk Encrypt / Decrypt Models
typescript
const userModels = [
{ id: "1", email: "alice@example.com" },
{ id: "2", email: "bob@example.com" },
]
const encrypted = await client.bulkEncryptModels(userModels, users)
const decrypted = await client.bulkDecryptModels(encrypted.data)
Searchable Encryption
Encrypt query terms so you can search encrypted data in PostgreSQL.
Single Query Encryption
typescript
// Equality query
const eqQuery = await client.encryptQuery("alice@example.com", {
column: users.email,
table: users,
queryType: "equality",
})
// Free-text search
const matchQuery = await client.encryptQuery("alice", {
column: users.email,
table: users,
queryType: "freeTextSearch",
})
// Order and range
const rangeQuery = await client.encryptQuery(25, {
column: users.age,
table: users,
queryType: "orderAndRange",
})
If
is omitted, it's auto-inferred from the column's configured indexes (priority: unique > match > ore > ste_vec).
Query Result Formatting ()
By default
returns an
object (the raw EQL JSON payload). Use
to change the output format:
| Output | Use case |
|---|
| (default) | object | Parameterized queries, ORMs accepting JSON |
| | Supabase , string-based APIs |
'escaped-composite-literal'
| | Embedding inside another string or JSON value |
typescript
// Get a composite literal string for use with Supabase
const term = await client.encryptQuery("alice@example.com", {
column: users.email,
table: users,
queryType: "equality",
returnType: "composite-literal",
})
// term.data is a string
Each term in a batch can have its own
.
Searchable JSON
For columns using
, the query type is auto-inferred from the plaintext:
typescript
// String -> JSONPath selector query
const pathQuery = await client.encryptQuery("$.user.email", {
column: documents.metadata,
table: documents,
})
// Object/Array -> containment query
const containsQuery = await client.encryptQuery({ role: "admin" }, {
column: documents.metadata,
table: documents,
})
Batch Query Encryption
Encrypt multiple query terms in one ZeroKMS call:
typescript
const terms = [
{ value: "alice@example.com", column: users.email, table: users, queryType: "equality" as const },
{ value: "bob", column: users.email, table: users, queryType: "freeTextSearch" as const },
]
const results = await client.encryptQuery(terms)
// results.data = [EncryptedPayload, EncryptedPayload]
Null values in the array are skipped and returned as null.
Identity-Aware Encryption (Lock Contexts)
Lock encryption to a specific user by requiring a valid JWT for decryption.
typescript
import { LockContext } from "@cipherstash/stack/identity"
// 1. Create a lock context (defaults to the "sub" claim)
const lc = new LockContext()
// Or with custom claims: new LockContext({ context: { identityClaim: ["sub", "org_id"] } })
// 2. Identify the user with their JWT
const identifyResult = await lc.identify(userJwt)
if (identifyResult.failure) {
throw new Error(identifyResult.failure.message)
}
const lockContext = identifyResult.data
// 3. Encrypt with lock context
const encrypted = await client
.encrypt("sensitive data", { column: users.email, table: users })
.withLockContext(lockContext)
// 4. Decrypt with the same lock context
const decrypted = await client
.decrypt(encrypted.data)
.withLockContext(lockContext)
Lock contexts work with ALL operations:
,
,
,
,
,
,
,
,
.
CTS Token Service
The lock context exchanges the JWT for a CTS (CipherStash Token Service) token. Set the endpoint:
bash
CS_CTS_ENDPOINT=https://ap-southeast-2.aws.auth.viturhosted.net
Multi-Tenant Encryption (Keysets)
Isolate encryption keys per tenant:
typescript
// By name
const client = await Encryption({
schemas: [users],
config: { keyset: { name: "Company A" } },
})
// By UUID
const client = await Encryption({
schemas: [users],
config: { keyset: { id: "123e4567-e89b-12d3-a456-426614174000" } },
})
Each keyset provides full cryptographic isolation between tenants.
Operation Chaining
All operations return thenable objects that support chaining:
typescript
const result = await client
.encrypt(plaintext, { column: users.email, table: users })
.withLockContext(lockContext) // optional: identity-aware
.audit({ metadata: { action: "create" } }) // optional: audit trail
Error Handling
All async methods return a
object - a discriminated union with either
(success) or
(error), never both.
typescript
const result = await client.encrypt("hello", { column: users.email, table: users })
if (result.failure) {
console.error(result.failure.type, result.failure.message)
// type is one of: "ClientInitError" | "EncryptionError" | "DecryptionError"
// | "LockContextError" | "CtsTokenError"
} else {
console.log(result.data)
}
Error Types
| Type | When |
|---|
| Client initialization fails (bad credentials, missing config) |
| An encrypt operation fails |
| A decrypt operation fails |
| Lock context creation or usage fails |
| Identity token exchange fails |
Validation Rules
- NaN and Infinity are rejected for numeric values
- index only supports string values
- At least one schema must be provided
- Keyset UUIDs must be valid format
PostgreSQL Storage
Encrypted data is stored as EQL (Encrypt Query Language) JSON payloads. Install the EQL extension in PostgreSQL:
sql
CREATE EXTENSION IF NOT EXISTS eql_v2;
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email eql_v2_encrypted
);
Or store as JSONB if not using the EQL extension directly:
sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email jsonb NOT NULL
);
Migration from @cipherstash/protect
| | Import Path |
|---|
| | |
| encryptedTable(name, cols)
| @cipherstash/stack/schema
|
| | @cipherstash/stack/schema
|
| from | from | @cipherstash/stack/identity
|
All method signatures on the encryption client remain the same. The
pattern is unchanged.
Complete API Reference
EncryptionClient Methods
| Method | Signature | Returns |
|---|
| (plaintext, { column, table })
| |
| | |
| (plaintext, { column, table, queryType?, returnType? })
| |
| (terms: readonly ScalarQueryTerm[])
| BatchEncryptQueryOperation
|
| | EncryptModelOperation<EncryptedFromSchema<T, S>>
|
| | |
| (plaintexts, { column, table })
| |
| | |
| | BulkEncryptModelsOperation<EncryptedFromSchema<T, S>>
|
| | BulkDecryptModelsOperation<T>
|
All operations are thenable (awaitable) and support
and
chaining.
Schema Builders
typescript
encryptedTable(tableName: string, columns: Record<string, EncryptedColumn | EncryptedField | nested>)
encryptedColumn(columnName: string) // chainable: .equality(), .freeTextSearch(), .orderAndRange(), .searchableJson(), .dataType()
encryptedField(valueName: string) // for nested encrypted fields (not searchable), chainable: .dataType()