You can encrypt data in-use with Protect.js and store it in your Supabase project all while maintaining the ability to search the data without decryption. This reference guide will show you how to do this with the Supabase SDK.
Note
The following assumes you have installed the latest version of the EQL v2 extension which has a specific release for Supabase, and gone through the Protect.js setup guide.
You need to define your column types as eql_v2_encrypted in your Supabase project, which is available after you install the EQL v2 extension.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name eql_v2_encrypted,
email eql_v2_encrypted
);Under the hood, the EQL payload is a JSON object that is stored as a composite type in the database.
You can insert encrypted data into the table using Protect.js and the Supabase SDK. Since the eql_v2_encrypted column is a composite type, you'll need to use the encryptedToPgComposite helper to properly format the data:
import {
protect,
csTable,
csColumn,
encryptedToPgComposite,
type ProtectClientConfig
} from '@cipherstash/protect'
const users = csTable('users', {
name: csColumn('name').freeTextSearch().equality(),
email: csColumn('email').freeTextSearch().equality()
})
const config: ProtectClientConfig = {
schemas: [users],
}
const protectClient = await protect(config)
const encryptedResult = await protectClient.encryptModel(
{
name: 'John Doe',
email: 'john.doe@example.com'
},
users
)
if (encryptedResult.failure) {
// Handle the failure
}
const { data, error } = await supabase
.from('users')
.insert([encryptedToPgComposite(encryptedResult.data)])When selecting encrypted data from the table using the Supabase SDK, it's important to cast the encrypted columns to jsonb to get the raw encrypted payload. This is necessary because the eql_v2_encrypted type is stored as a composite type in PostgreSQL:
const { data, error } = await supabase
.from('users')
.select('id, email::jsonb, name::jsonb')Without the ::jsonb cast, the encrypted payload would be wrapped in an object with a data key, which would require additional handling before decryption. The cast ensures you get the raw encrypted payload that can be directly used with Protect.js for decryption:
const decryptedResult = await protectClient.decryptModel(data[0])
if (decryptedResult.failure) {
// Handle the failure
}
console.log('Decrypted user:', decryptedResult.data)When working with models that contain multiple encrypted fields, you can use the modelToEncryptedPgComposites helper to handle the conversion to PostgreSQL composite types:
import {
protect,
csTable,
csColumn,
modelToEncryptedPgComposites,
type ProtectClientConfig
} from '@cipherstash/protect'
const users = csTable('users', {
name: csColumn('name').freeTextSearch().equality(),
email: csColumn('email').freeTextSearch().equality()
})
const config: ProtectClientConfig = {
schemas: [users],
}
const protectClient = await protect(config)
const model = {
name: 'John Doe',
email: 'john.doe@example.com',
otherField: 'not encrypted'
}
const encryptedModel = await protectClient.encryptModel(model, users)
const { data, error } = await supabase
.from('users')
.insert([modelToEncryptedPgComposites(encryptedModel.data)])For bulk operations with multiple models, you can use bulkEncryptModels and bulkModelsToEncryptedPgComposites:
const models = [
{
name: 'John Doe',
email: 'john.doe@example.com',
otherField: 'not encrypted 1'
},
{
name: 'Jane Smith',
email: 'jane.smith@example.com',
otherField: 'not encrypted 2'
}
]
const encryptedModels = await protectClient.bulkEncryptModels(models, users)
const { data, error } = await supabase
.from('users')
.insert(bulkModelsToEncryptedPgComposites(encryptedModels.data))
.select('id')
// When selecting multiple records, remember to use ::jsonb
const { data: selectedData, error: selectError } = await supabase
.from('users')
.select('id, name::jsonb, email::jsonb, otherField')
// Decrypt all models at once
const decryptedModels = await protectClient.bulkDecryptModels(selectedData)These instructions are referenced from the Supabase docs and are used to expose the EQL schema to the Supabase SDK.
- Go to API settings and add
eql_v2to "Exposed schemas". - Then run the following in the Supabase project as raw SQL:
GRANT USAGE ON SCHEMA eql_v2 TO anon, authenticated, service_role;Add commentMore actions
GRANT ALL ON ALL TABLES IN SCHEMA eql_v2 TO anon, authenticated, service_role;
GRANT ALL ON ALL ROUTINES IN SCHEMA eql_v2 TO anon, authenticated, service_role;
GRANT ALL ON ALL SEQUENCES IN SCHEMA eql_v2 TO anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v2 GRANT ALL ON TABLES TO anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v2 GRANT ALL ON ROUTINES TO anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v2 GRANT ALL ON SEQUENCES TO anon, authenticated, service_role;When searching encrypted data, you need to convert the encrypted payload into a format that PostgreSQL and the Supabase SDK can understand. The encrypted payload needs to be converted to a raw composite type format by double stringifying the JSON:
const searchTerms = await protectClient.createSearchTerms([
{
value: 'billy@example.com',
column: users.email,
table: users,
returnType: 'composite-literal'
}
])
const searchTerm = searchTerms.data[0]For certain queries, when including the encrypted search term with an operator that uses the string logic syntax, you need to use the 'escaped-composite-literal' return type:
const searchTerms = await protectClient.createSearchTerms([
{
value: 'billy@example.com',
column: users.email,
table: users,
returnType: 'escaped-composite-literal'
}
])
const searchTerm = searchTerms.data[0]Here are examples of different ways to search encrypted data using the Supabase SDK:
const searchTerms = await protectClient.createSearchTerms([
{
value: 'billy@example.com',
column: users.email,
table: users,
returnType: 'composite-literal'
}
])
const { data, error } = await supabase
.from('users')
.select('id, email::jsonb, name::jsonb')
.eq('email', searchTerms.data[0])const searchTerms = await protectClient.createSearchTerms([
{
value: 'example.com',
column: users.email,
table: users,
returnType: 'composite-literal'
}
])
const { data, error } = await supabase
.from('users')
.select('id, email::jsonb, name::jsonb')
.like('email', searchTerms.data[0])When you need to search for multiple encrypted values, you can use the IN operator. Each encrypted value needs to be properly formatted and combined:
// Encrypt multiple search terms
const searchTerms = await protectClient.createSearchTerms([
{
value: 'value1',
column: users.name,
table: users,
returnType: 'escaped-composite-literal'
},
{
value: 'value2',
column: users.name,
table: users,
returnType: 'escaped-composite-literal'
}
])
// Combine terms for IN operator
const { data, error } = await supabase
.from('users')
.select('id, email::jsonb, name::jsonb')
.filter('name', 'in', `(${searchTerms.data[0]},${searchTerms.data[1]})`)You can combine multiple encrypted search conditions using the .or() syntax. This is useful when you want to search across multiple encrypted columns:
// Encrypt search terms for different columns
const searchTerms = await protectClient.createSearchTerms([
{
value: 'user@example.com',
column: users.email,
table: users,
returnType: 'escaped-composite-literal'
},
{
value: 'John',
column: users.name,
table: users,
returnType: 'escaped-composite-literal'
}
])
// Combine conditions with OR
const { data, error } = await supabase
.from('users')
.select('id, email::jsonb, name::jsonb')
.or(`email.ilike.${searchTerms.data[0]}, name.ilike.${searchTerms.data[1]}`)The key is in using the appropriate return type for your search terms:
- Use
composite-literalfor simple equality and pattern matching queries - Use
escaped-composite-literalwhen you need to include the search term in string-based operators like IN or OR conditions
You can use these patterns with any of Supabase's query methods like .eq(), .like(), .ilike(), etc.