Security
How to secure data access and encrypt data with Electric.
Data access
Electric is a sync service that runs in front of Postgres. It connects to a Postgres database using a DATABASE_URL
and exposes the data in that database via an HTTP API.
This API is public by default. It should be secured in production using network security and/or an authorization proxy.
Public by default
Electric connects to Postgres as a normal database user. It then exposes access to any data that its database user can access in Postgres to any client that can connect to the Electric HTTP API.
You generally do not want to expose public access to the contents of your database, so you must secure access to the Electric HTTP API.
Network security
One way of securing access to Electric is to use a network firewall or IP whitelist.
You can often configure this using the networking rules of your cloud provider. Or you can use these to restrict public access to Electric and only expose Electric via a reverse-proxy such as Nginx or Caddy. This reverse proxy can then enforce network security rules, for example, using Caddy's remote-ip
request matcher:
@denied not remote_ip 100.200.30.40 100.200.30.41
abort @denied
This approach is useful when you're using Electric to sync into trusted infrastructure. However, it doesn't help when you're syncing data into client devices, like apps and web browsers. For those, you need to restrict access using an authorizing proxy.
Authorization
Electric is designed to run behind an authorizing proxy.
This is the primary method for securing data access to clients and apps and is documented in detail, with examples, in the Auth guide.
Encryption
Electric syncs ciphertext as well as it syncs plaintext. You can encrypt and decrypt data in HTTP middleware or in the local client.
End-to-end encryption
For example, you can achieve end-to-end encryption by:
- encrypting data before it leaves the client
- decrypting data when it comes off the replication stream into the client
You can see an example of this in the encryption example:
import base64 from 'base64-js'
import React, { useEffect, useState } from 'react'
import { useShape } from '@electric-sql/react'
import './Example.css'
type Item = {
id: string
title: string
}
type EncryptedItem = {
id: string
ciphertext: string
iv: string
}
const API_URL = import.meta.env.API_URL || 'http://localhost:3001'
const ELECTRIC_URL = import.meta.env.ELECTRIC_URL ?? 'http://localhost:3000'
// For this example, we hardcode a deterministic key that works across page loads.
// In a real app, you would implement a key management strategy. Electric is great
// at syncing keys between users :)
const rawKey = new Uint8Array(16)
const key = await crypto.subtle.importKey('raw', rawKey, 'AES-GCM', true, [
'encrypt',
'decrypt',
])
/*
* Encrypt an `Item` into an `EncryptedItem`.
*/
async function encrypt(item: Item): Promise<EncryptedItem> {
const { id, title } = item
const enc = new TextEncoder()
const encoded = enc.encode(title)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt(
{
iv,
name: 'AES-GCM',
},
key,
encoded
)
const ciphertext = base64.fromByteArray(new Uint8Array(encrypted))
const iv_str = base64.fromByteArray(iv)
return {
id,
ciphertext,
iv: iv_str,
}
}
/*
* Decrypt an `EncryptedItem` to an `Item`.
*/
async function decrypt(item: EncryptedItem): Promise<Item> {
const { id, ciphertext, iv: iv_str } = item
const encrypted = base64.toByteArray(ciphertext)
const iv = base64.toByteArray(iv_str)
const decrypted = await crypto.subtle.decrypt(
{
iv,
name: 'AES-GCM',
},
key,
encrypted
)
const dec = new TextDecoder()
const title = dec.decode(decrypted)
return {
id,
title,
}
}
export const Example = () => {
const [items, setItems] = useState<Item[]>()
const { data } = useShape<EncryptedItem>({
url: `${ELECTRIC_URL}/v1/shape`,
params: {
table: 'items',
},
})
const rows = data !== undefined ? data : []
// There are more efficient ways of updating state than always decrypting
// all the items on any change but just to demonstate the decryption ...
useEffect(() => {
async function init() {
const items = await Promise.all(
rows.map(async (row) => await decrypt(row))
)
setItems(items)
}
init()
}, [rows])
/*
* Handle adding an item by creating the item data, encrypting it
* and sending it to the API
*/
async function createItem(event: React.FormEvent) {
event.preventDefault()
const form = event.target as HTMLFormElement
const formData = new FormData(form)
const title = formData.get('title') as string
const id = crypto.randomUUID()
const item = {
id,
title,
}
const data = await encrypt(item)
const url = `${API_URL}/items`
const options = {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
}
await fetch(url, options)
form.reset()
}
if (items === undefined) {
return <div>Loading...</div>
}
return (
<div>
<div>
{items.map((item: Item, index: number) => (
<p key={index} className="item">
<code>{item.title}</code>
</p>
))}
</div>
<form onSubmit={createItem}>
<input
type="text"
name="title"
placeholder="Type here …"
required
/>
<button type="submit">Add</button>
</form>
</div>
)
}
Key management
One of the primary challenges with encryption is key management. I.e.: choosing which data to encrypt with which keys and sharing the right keys with the right users.
Electric doesn't provide or prescribe any specific key management solution. You're free to use any existing key management system, such as Hashicorp Vault, for key management. However, for end-to-end encryption of shared data, you will at some point need to share keys between clients. This is a job that Electric is good at: syncing the right data to the right users.
For example, imagine you store keys in a seperate, extra secure, Postgres database and you segment your encryption by tenant (or group, or some other shared resource). You could sync keys to the client using a shape like this:
import { ShapeStream } from '@electric-sql/client'
const stream = new ShapeStream({
url: `${ELECTRIC_URL}/v1/shape`,
params: {
table: 'tenants',
columns: [
'keys'
],
where: `id in ('${user.tenant_ids.join(`', '`)}')`
}
})
You could then put a denormalised tenant_id
column on all of the synced tables in your main database and lookup the correct key to use when decrypting and encrypting the row in the client.