Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ jobs:
echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env
echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> ./packages/stack/.env
echo "SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" >> ./packages/stack/.env
echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> ./packages/stack/.env
echo "CS_ZEROKMS_HOST=https://ap-southeast-2.aws.zerokms.cipherstashmanaged.net" >> ./packages/stack/.env
echo "CS_CTS_HOST=https://ap-southeast-2.aws.cts.cipherstashmanaged.net" >> ./packages/stack/.env

Expand Down
110 changes: 101 additions & 9 deletions docs/reference/searchable-encryption-postgres.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ This reference guide outlines the different query patterns you can use to search
- [Prerequisites](#prerequisites)
- [What is EQL?](#what-is-eql)
- [Setting up your schema](#setting-up-your-schema)
- [The `encryptQuery` function](#the-encryptquery-function)
- [Formatting encrypted query results with `returnType`](#formatting-encrypted-query-results-with-returntype)
- [Search capabilities](#search-capabilities)
- [JSONB queries with searchableJson (recommended)](#jsonb-queries-with-searchablejson-recommended)
- [JSONPath selector queries](#jsonpath-selector-queries)
Expand Down Expand Up @@ -79,8 +81,10 @@ The `encryptQuery` function is used to create encrypted query terms for use in S
| `value` | The value to search for |
| `column` | The column to search in |
| `table` | The table to search in |
| `queryType` | _(optional)_ The query type — auto-inferred from the column's indexes when omitted |
| `returnType` | _(optional)_ The output format — `'eql'` (default), `'composite-literal'`, or `'escaped-composite-literal'` |

**Batch query** — pass an array of objects, each with the properties above.
**Batch query** — pass an array of objects, each with the properties above (including `value`).

Example (single query):

Expand Down Expand Up @@ -112,6 +116,58 @@ if (terms.failure) {
console.log(terms.data) // array of encrypted query terms
```

### Formatting encrypted query results with `returnType`

By default, `encryptQuery` returns an `Encrypted` object (the raw EQL JSON payload). You can change the output format using the `returnType` option:

| `returnType` | Return type | Description |
|---|---|---|
| `'eql'` (default) | `Encrypted` object | Raw EQL JSON payload. Use with parameterized queries (`$1`) or ORMs that accept JSON objects. |
| `'composite-literal'` | `string` | PostgreSQL composite literal format `("json")`. Use with Supabase `.eq()` or other APIs that require a string value. |
| `'escaped-composite-literal'` | `string` | Escaped composite literal `"(\"json\")"`. Use when the query string will be embedded inside another string or JSON value. |

The return type of `encryptQuery` is `EncryptedQueryResult`, which is `Encrypted | string | null` depending on the `returnType` and whether the input was `null`.

**Single query with `returnType`:**

```typescript
const term = await client.encryptQuery('user@example.com', {
column: schema.email,
table: schema,
queryType: 'equality',
returnType: 'composite-literal',
})

if (term.failure) {
// Handle the error
}

// term.data is a string in composite literal format
await supabase.from('users').select().eq('email_encrypted', term.data)
```

**Batch query with `returnType`:**

Each term in a batch can have its own `returnType`:

```typescript
const terms = await client.encryptQuery([
{
value: 'user@example.com',
column: schema.email,
table: schema,
queryType: 'equality',
returnType: 'composite-literal', // returns a string
},
{
value: 'alice',
column: schema.email,
table: schema,
queryType: 'freeTextSearch', // returns an Encrypted object (default)
},
])
```

## Search capabilities

### JSONB queries with searchableJson (recommended)
Expand Down Expand Up @@ -257,27 +313,63 @@ console.log(terms.data) // array of encrypted query terms

#### Using JSONB queries in SQL

To use encrypted JSONB query terms in PostgreSQL queries, specify `returnType: 'composite-literal'` to get the terms formatted for direct use in SQL:
To use encrypted JSONB query terms in PostgreSQL queries, you can either use the default `Encrypted` object with parameterized queries, or use `returnType: 'composite-literal'` to get a string formatted for direct use with Supabase or similar APIs.

**With parameterized queries (default `returnType`):**

```typescript
const term = await client.encryptQuery([{
value: '$.user.email',
const term = await client.encryptQuery('$.user.email', {
column: documents.metadata,
table: documents,
returnType: 'composite-literal',
}])
})

if (term.failure) {
// Handle the error
}

// Use the encrypted term in a PostgreSQL query
const result = await client.query(
// Pass the EQL object as a parameterized query value
const result = await pgClient.query(
'SELECT * FROM documents WHERE cs_ste_vec_v2(metadata_encrypted) @> $1',
[term.data[0]]
[term.data]
)
```

**With Supabase or string-based APIs (`returnType: 'composite-literal'`):**

```typescript
const term = await client.encryptQuery('$.user.email', {
column: documents.metadata,
table: documents,
returnType: 'composite-literal',
})

if (term.failure) {
// Handle the error
}

// term.data is a string — use directly with .eq(), .contains(), etc.
await supabase.from('documents').select().contains('metadata_encrypted', term.data)
```

This also works with batch queries — each term can specify its own `returnType`:

```typescript
const terms = await client.encryptQuery([
{
value: '$.user.email',
column: documents.metadata,
table: documents,
returnType: 'composite-literal',
},
{
value: { role: 'admin' },
column: documents.metadata,
table: documents,
returnType: 'composite-literal',
},
])
```

#### Advanced: Explicit query types

For advanced use cases, you can specify the query type explicitly instead of relying on auto-inference:
Expand Down
95 changes: 75 additions & 20 deletions packages/stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,37 +264,92 @@ const terms = [
const results = await client.encryptQuery(terms)
```

### PostgreSQL / Drizzle Integration Pattern
### Query Result Formatting (`returnType`)

By default `encryptQuery` returns an `Encrypted` object (the raw EQL JSON payload). Use `returnType` to change the output format:

Encrypted data is stored as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload. Install the EQL extension in PostgreSQL to enable searchable queries, then store encrypted data in `eql_v2_encrypted` columns:
| `returnType` | Output | Use case |
|---|---|---|
| `'eql'` (default) | `Encrypted` object | Parameterized queries, ORMs accepting JSON |
| `'composite-literal'` | `string` | Supabase `.eq()`, string-based APIs |
| `'escaped-composite-literal'` | `string` | Embedding inside another string or JSON value |

```sql
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email eql_v2_encrypted
);
```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 — use directly with .eq()
await supabase.from("users").select().eq("email", term.data)
```

Each term in a batch can have its own `returnType`.

### PostgreSQL / Drizzle Integration Pattern

Encrypted data is stored as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload. Install the EQL extension in PostgreSQL to enable searchable queries, then store encrypted data in `eql_v2_encrypted` columns.

The `@cipherstash/stack/drizzle` module provides `encryptedType` for defining encrypted columns and `createEncryptionOperators` for querying them:

```typescript
import { eq } from "drizzle-orm"
import { pgTable, serial, jsonb } from "drizzle-orm/pg-core"
import { pgTable, integer, timestamp } from "drizzle-orm/pg-core"
import { encryptedType, extractEncryptionSchema, createEncryptionOperators } from "@cipherstash/stack/drizzle"
import { Encryption } from "@cipherstash/stack"

// Define schema with encrypted columns
const usersTable = pgTable("users", {
id: serial("id").primaryKey(),
email: jsonb("email").notNull(),
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
email: encryptedType<string>("email", {
equality: true,
freeTextSearch: true,
orderAndRange: true,
}),
profile: encryptedType<{ name: string; bio: string }>("profile", {
dataType: "json",
searchableJson: true,
}),
})

// Insert encrypted data
await db.insert(usersTable).values({ email: encrypted.data })
// Initialize
const usersSchema = extractEncryptionSchema(usersTable)
const client = await Encryption({ schemas: [usersSchema] })
const ops = createEncryptionOperators(client)

// Search with encrypted query
const encQuery = await client.encryptQuery("alice@example.com", {
column: users.email,
table: users,
queryType: "equality",
})
// Query with auto-encrypting operators
const results = await db.select().from(usersTable)
.where(await ops.eq(usersTable.email, "alice@example.com"))

// JSONB queries on encrypted JSON columns
const jsonResults = await db.select().from(usersTable)
.where(await ops.jsonbPathExists(usersTable.profile, "$.bio"))
```

#### Drizzle `encryptedType` Config Options

| Option | Type | Description |
|---|---|---|
| `dataType` | `"string"` \| `"number"` \| `"json"` | Plaintext data type (default: `"string"`) |
| `equality` | `boolean` \| `TokenFilter[]` | Enable equality index |
| `freeTextSearch` | `boolean` \| `MatchIndexOpts` | Enable free-text search index |
| `orderAndRange` | `boolean` | Enable ORE index for sorting/range queries |
| `searchableJson` | `boolean` | Enable JSONB path queries (requires `dataType: "json"`) |

#### Drizzle JSONB Operators

For columns with `searchableJson: true`, three JSONB operators are available:

| Operator | Description |
|---|---|
| `jsonbPathExists(col, selector)` | Check if a JSONB path exists (boolean, use in `WHERE`) |
| `jsonbPathQueryFirst(col, selector)` | Extract first value at a JSONB path |
| `jsonbGet(col, selector)` | Get value using the JSONB `->` operator |

These operators encrypt the JSON path selector using the `steVecSelector` query type and cast it to `eql_v2_encrypted` for use with the EQL PostgreSQL functions.

## Identity-Aware Encryption

Lock encryption to a specific user by requiring a valid JWT for decryption.
Expand Down Expand Up @@ -535,7 +590,7 @@ function Encryption(config: EncryptionClientConfig): Promise<EncryptionClient>
|----|------|-----|
| `encrypt` | `(plaintext, { column, table })` | `EncryptOperation` (thenable) |
| `decrypt` | `(encryptedData)` | `DecryptOperation` (thenable) |
| `encryptQuery` | `(plaintext, { column, table, queryType? })` | `EncryptQueryOperation` (thenable) |
| `encryptQuery` | `(plaintext, { column, table, queryType?, returnType? })` | `EncryptQueryOperation` (thenable) |
| `encryptQuery` | `(terms: ScalarQueryTerm[])` | `BatchEncryptQueryOperation` (thenable) |
| `encryptModel` | `(model, table)` | `EncryptModelOperation<T>` (thenable) |
| `decryptModel` | `(encryptedModel)` | `DecryptModelOperation<T>` (thenable) |
Expand Down
Loading