πŸŽ‰ Native Google & Apple sign-in is here β†’ read the guide
End-to-End Encryption

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:

  1. 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.
  2. 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.
  3. 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 haveEncrypt withDecrypt with
ByteArrayencrypt(bytes)decrypt(ciphertext)
Stringencrypt(text)decryptToString(ciphertext)
@Serializable valueencryptValue(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 restarts

Because 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.