End-to-End Encryption
Supabase encrypts your data in transit (TLS) and at rest, but the server still sees your rows in plaintext β it has to, to filter and index them. For a chat app, a notes app, or anything where only the two ends should ever read the content, that isnβt enough. End-to-end encryption (E2EE) means the data is encrypted on the senderβs device and decrypted on the receiverβs device, and the key never leaves either device. Supabase becomes a blind courier: it stores and relays ciphertext it cannot read.
The optional supabase-e2ee module gives you exactly that β a small, no-magic
helper that encrypts any data with keys derived on-device.
This is a building block, not a full secure-messaging protocol. It gives you a shared symmetric key and authenticated encryption. It does not implement forward secrecy (key ratcheting), multi-device key sync, or identity verification β for those you build on top. See Threat model.
Install
[libraries]
supabase-e2ee = { module = "io.github.androidpoet:supabase-e2ee", version.ref = "supabase-kmp" }commonMain.dependencies {
implementation(libs.supabase.e2ee)
}Itβs a standalone module β it depends only on supabase-core (for
SupabaseResult) and a multiplatform crypto backend, so it works on every target
the SDK supports, including WebAssembly.
What is βthe keyβ?
This is the part people get wrong. The key is never stored on the server, and neither side ever transmits it. Each side derives the same key locally:
- Each device generates an ECDH key pair (curve P-256). The public key is safe to share β publish it in a Supabase table. The private key stays on the device.
- To talk to a peer, you fetch their public key and combine it with your
private key. Elliptic-curve Diffie-Hellman (ECDH) has a magic property: your
(yourPrivate, theirPublic)produces the exact same shared secret as their(theirPrivate, yourPublic). Both sides arrive at the same bytes without that secret ever crossing the wire. - That raw shared secret is not used as the key directly (that would be insecure). Itβs run through HKDF-SHA256 to derive a clean, uniformly random AES-256-GCM key. That AES key is what encrypts your messages.
So βthe keyβ is a 256-bit AES-GCM key that exists only in the RAM of the two devices. The server stores public keys and ciphertext β never the key, never the plaintext.
Encrypt and decrypt
Generate a key pair once per device, publish the public key, and derive a session when you want to talk to someone:
// On each device, once. Save the private key in secure storage; publish publicKey.
val mine = generateE2eeKeyPair().getOrThrow()
// e.g. database.insert("device_keys", mapOf("user_id" to me, "public_key" to mine.publicKey.toBase64()))
// Fetch the peer's published public key, then derive the shared session.
val session = mine.deriveSession(peerPublicKey).getOrThrow()
// Encrypt β the result is ciphertext you can store/send as-is (the nonce is embedded).
val ciphertext = session.encrypt("hello, this is private").getOrThrow()
// On the other device (which derived the same session from your public key):
val text = session.decryptToString(ciphertext).getOrThrow() // "hello, this is private"Every call returns a SupabaseResult β a wrong key, tampered ciphertext, or a
backend error comes back as Failure, never an uncaught exception.
Works with any data
The session encrypts raw bytes, strings, or any @Serializable value:
@Serializable
data class ChatMessage(val from: String, val body: String, val sentAt: Long)
val msg = ChatMessage("alice", "see you at 6", 1_700_000_000)
// Serializes to JSON, then encrypts.
val blob = session.encryptValue(msg).getOrThrow()
// Decrypts, then deserializes back into your type.
val restored = session.decryptValue<ChatMessage>(blob).getOrThrow()| You have | Encrypt with | Decrypt with |
|---|---|---|
ByteArray | encrypt(bytes) | decrypt(ciphertext) |
String | encrypt(text) | decryptToString(ciphertext) |
@Serializable value | encryptValue(value) | decryptValue<T>(ciphertext) |
Store the resulting ByteArray (or its Base64) in any column, file, or realtime
broadcast β Supabase never needs to understand it.
How it fits a Supabase chat app
βββ Device A βββ Supabase βββ Device B βββ
β keypair A β publish pubA ββββββββββββββββ pubB β keypair B β
β derive(pubB) β ββββββββββββββΊ β device_keys β ββββββ β derive(pubA) β
β = key K β β messages β β = key K β
β encrypt(K)ββββββciphertextββββΊβ (ciphertext) βββββββββΊβ decrypt(K) β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
both sides independently arrive at the SAME key K- Store each userβs public key in a table; everyone can read it.
- Keep each deviceβs private key in platform secure storage β the module is deliberately BYO-storage and wonβt persist keys for you.
- Send
encryptValue(message)output through a normal table insert or a realtime broadcast. The server, your logs, and any DB admin only ever see ciphertext.
Worked example: encrypted chat with the Database module
This is the full loop with supabase-database β create keys, encrypt the data as
you generate it, store only ciphertext, then read it back and decrypt. Start with
two tables:
-- Each device publishes its PUBLIC key here (safe to share).
create table device_keys (
user_id uuid primary key references auth.users (id),
public_key text not null
);
-- Messages hold ONLY ciphertext β the server never sees plaintext.
create table messages (
id bigint generated always as identity primary key,
room text not null,
sender uuid not null,
ciphertext text not null, -- Base64 of encrypted bytes
created_at timestamptz not null default now()
);Ciphertext is raw bytes, so we Base64-encode it for a text column:
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@Serializable data class DeviceKey(val user_id: String, val public_key: String)
@Serializable data class ChatRow(val room: String, val sender: String, val ciphertext: String)1. Once per device β generate a key pair, publish the public key, keep the private key in secure storage:
@OptIn(ExperimentalEncodingApi::class)
suspend fun registerDevice(database: DatabaseClient, userId: String): E2eeKeyPair {
val keyPair = generateE2eeKeyPair().getOrThrow()
database.insertTyped(
table = "device_keys",
value = DeviceKey(userId, Base64.encode(keyPair.publicKey)),
upsert = true,
).getOrThrow()
// Persist the private key β keyPair.exportPrivateKey() β in Keychain / Keystore,
// NOT in a column. See "Encrypt your own data" below for the round-trip.
return keyPair
}2. Send a message β fetch the peerβs public key, derive the session, and encrypt the data before it ever leaves the device:
@OptIn(ExperimentalEncodingApi::class)
suspend fun send(
database: DatabaseClient,
mine: E2eeKeyPair,
myUserId: String,
peerUserId: String,
room: String,
text: String,
) {
val peer = database.selectTyped<DeviceKey>("device_keys") {
eq("user_id", peerUserId)
}.getOrThrow().first()
val session = mine.deriveSession(Base64.decode(peer.public_key)).getOrThrow()
val ciphertext = session.encrypt(text).getOrThrow() // encrypt, THEN insert
database.insertTyped(
table = "messages",
value = ChatRow(room, myUserId, Base64.encode(ciphertext)),
).getOrThrow()
}3. Read messages β derive the same session and decrypt each row:
@OptIn(ExperimentalEncodingApi::class)
suspend fun history(
database: DatabaseClient,
mine: E2eeKeyPair,
peerUserId: String,
room: String,
): List<String> {
val peer = database.selectTyped<DeviceKey>("device_keys") {
eq("user_id", peerUserId)
}.getOrThrow().first()
val session = mine.deriveSession(Base64.decode(peer.public_key)).getOrThrow()
return database.selectTyped<ChatRow>("messages") {
eq("room", room)
order("created_at", ascending = true)
}.getOrThrow().map { row ->
session.decryptToString(Base64.decode(row.ciphertext)).getOrThrow()
}
}Thatβs the whole pattern: derive the session once, encrypt right before any
insert, decrypt right after any select. The messages table β and anyone who
can read it β only ever holds Base64 ciphertext. To send structured payloads
instead of plain strings, swap encrypt/decryptToString for
encryptValue/decryptValue<T>.
The same pattern works over Realtime: broadcast Base64.encode(ciphertext)
instead of inserting it, and decrypt on the receiving end. The relay never sees
plaintext.
Encrypt your own data (at rest)
E2EE isnβt only for talking to someone else. The same primitives let you encrypt
your own data β notes, files, a local cache β so that even though it lives in a
Supabase row, only your device can read it. Thereβs no peer here, so instead of
deriveSession(peerPublicKey) you derive a self-session: a key the pair makes
against its own public key. The same pair always yields the same key.
The catch: a self-key is only useful if the same key pair comes back after the app restarts. A freshly generated pair canβt read yesterdayβs ciphertext. So you export the private key once, persist it, and re-import it on the next launch:
import io.github.androidpoet.supabase.e2ee.exportPrivateKey
import io.github.androidpoet.supabase.e2ee.importE2eeKeyPair
import io.github.androidpoet.supabase.e2ee.deriveSelfSession
// First run: generate the pair once, then persist it.
val pair = generateE2eeKeyPair().getOrThrow()
val privateDer = pair.exportPrivateKey().getOrThrow() // PKCS#8 DER bytes
// Store privateDer in Keychain / Keystore (BYO secure storage); keep pair.publicKey
// alongside it. Never upload the private key.
// Next launch: restore the SAME pair, then derive the SAME self-key.
val restored = importE2eeKeyPair(privateDer, savedPublicKey).getOrThrow()
val session = restored.deriveSelfSession().getOrThrow()
val blob = session.encrypt("my private note").getOrThrow() // store the ciphertext
val text = session.decryptToString(blob).getOrThrow() // reads across restartsBecause deriveSelfSession() keys against the pairβs own public key, the AES key is
deterministic for a given pair β persist the pair and your ciphertext stays
readable forever. The exported DER is interoperable: it round-trips across the
JDK, Apple, OpenSSL and WebCrypto backends, so a note encrypted on Android decrypts
on the same userβs iOS device once the private key is synced there (by you).
The exported private key is the whole secret β anyone who has it can read every value you encrypted. Keep it in platform secure storage, and never put it in a column, a log, or a backup you donβt control.
Threat model
What this does protect against:
- A compromised or curious server / database operator reading message content.
- Content leaking through logs, backups, or replication β itβs all ciphertext.
- Tampering: AES-GCM is authenticated, so altered ciphertext fails to decrypt.
What it does not do (build these on top if you need them):
- Forward secrecy β one long-lived key pair per device means a leaked private key can decrypt past messages. Rotate keys, or layer a ratchet on top.
- Identity verification (MITM) β fetching a public key from the server trusts the server to hand you the right one. For sensitive use, verify keys out-of-band (QR code, safety number).
- Key storage β you choose where the private key lives; choose platform secure storage, not plaintext.
The construction β ECDH key agreement β HKDF-SHA256 β AES-256-GCM, never using the raw shared secret as a key β follows standard authenticated-messaging practice. P-256 is used (over X25519) because itβs supported by every platform backend, including WebCrypto on Wasm.