Connect an Expo app to a Convex encrypted-envelope relay
Replace the in-memory relay with a Convex deployment you own, and get one-time-prekey consumption right under concurrency.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working Expo client from Guides → Build an encrypted Expo conversation, and a Convex deployment you own
- Reading time
- 15 min
The previous guide stopped at inMemoryRelay(): an in-memory relay with no authentication, no persistence, and no concurrency. This one replaces it with a relay you own.
Define the ownership boundary before you write code: the SDK ships an adapter, not a hosted relay.
This package has no OpenE2EE endpoint, account, or Convex component. ConvexSignalProtocolRelayServer maps your generated Convex API module to ISignalProtocolRelayServer. Your application owns its Convex deployment, schema, authentication, authorization, functions, retention policy, and operational controls.
This design requires more work than a hosted service. It also makes the relay replaceable without changes to protocol code. OpenE2EE does not receive your users' metadata.
Intended audience
You have an Expo client from build an encrypted Expo conversation that sends messages through inMemoryRelay(). Now you need delivery between devices. You can write Convex queries and mutations and reason about transactions.
Prerequisites
- A working client from the Expo guide: development build,
ExpoSignalProtocolStore,ExpoSecureStoreSignalProtocolSecretVault. - A Convex deployment you control, with authentication configured. Every write below derives its identity from
ctx.auth. A relay that trusts a client-supplied user ID is not a relay, it is a forgery service. - Relay and prekeys for the contract, and Convex relay integration for the adapter's boundary in isolation.
0.1.x; public APIs and persisted formats may change before 1.0.
What this guide builds
Install
npm install @open-e2ee/signal-protocol-sdk@0.1.0 convexStage 1: the function namespace
The adapter calls a nested namespace of Convex function references described by ConvexSignalProtocolRelayApi. You define every one of them. The exact shape, verbatim from the adapter's types:
messages:send,getPendingMessages,markDelivered,getGroupMembers,getActiveDevices,sendUnidentified,sendMultiRecipientUnidentified,sendRetryRequest,getPendingRetryRequests,markRetryRequestHandleddevices:getDevices,registerDevice,removeDevice,markDeviceConnected,markDeviceDisconnected,presenceHeartbeatkeys:uploadIdentityKey,getIdentityKey,uploadPreKeys,fetchPreKeyBundle,getPreKeyCount,clearStaleKemPreKeys,uploadEcSignedPreKey,uploadKemLastResortPreKey,getEcSignedPreKeyMetadata,getKemLastResortPreKeyMetadatacertificates:issueSenderCertificateprovisioning:createProvisioningSession,connectNewDevice,sendProvisioningMessage,getProvisioningMessage,completeProvisioning,acknowledgeProvisioning,rollbackProvisioning,deleteProvisioningSessiongroups:createGroup,getGroup,getGroupChanges,submitGroupChange,refreshGroupSendEndorsementszkAuth:issueAuthCredentialMutation
Whether each is a query or a mutation is not cosmetic. The generated API types keys.fetchPreKeyBundle as a mutation because a bundle fetch consumes a one-time prekey. Hold that thought.
Organise them under one generated namespace, convex/signal/* exported as api.signal, so the adapter can take the namespace whole.
Stage 2: wire the adapter
import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { expoStore } from '@open-e2ee/signal-protocol-sdk/local/store/expo';
import {
convexRelay,
type ConvexSignalProtocolRelayApi,
} from '@open-e2ee/signal-protocol-sdk/remote/relay/convex';
import { api } from '../convex/_generated/api';
const signalApi = api.signal satisfies ConvexSignalProtocolRelayApi;
const relay = convexRelay({
convex,
api: signalApi,
currentUserId: userId,
getAuthToken: async () => authToken,
});
const signal = await createSignalProtocolClient({
identity: { userId },
adapters: { storage: expoStore({ relay }), relay },
});
await signal.syncToServer();
signal.registerHook('onMessageDecrypted', persistMessage);
signal.startRelaySubscription();The class form is equivalent and positional:
import { ConvexSignalProtocolRelayServer } from '@open-e2ee/signal-protocol-sdk/remote/relay/convex';
const relay = new ConvexSignalProtocolRelayServer(convex, signalApi, { currentUserId: userId });That is the entire diff from the previous guide: inMemoryRelay() becomes convexRelay({...}). Nothing else in the client changes, which is the point of an adapter boundary.
satisfies ConvexSignalProtocolRelayApi provides a compile-time error for a missing function or wrong type. It preserves the generated references and their argument types.
getAuthToken is optional and changes the transport: supplied, the adapter creates an internal Convex client with WebSocket push, reading EXPO_PUBLIC_CONVEX_URL. Omitted, it falls back to polling messages.getPendingMessages. Both deliver. Only one is instant.
Stage 3: authorization is the part the SDK cannot do
Look at what the adapter sends when it delivers an envelope: targetUserId, targetDeviceId, senderDeviceId, ciphertext, messageType, urgent, ephemeral, groupId, timestamp, clientMessageId, recipientRegistrationId.
senderUserId is absent, deliberately. The server derives it from JWT auth. The same applies to devices.registerDevice, which sends deviceId, encryptedDeviceName, and deviceType but no user ID.
If your mutation accepts a caller-supplied sender identity, any authenticated user can forge an envelope's from field. The protocol cannot detect this product-layer error. The ciphertext remains authentic, but the attacker controls the displayed sender identity.
// convex/signal/messages.ts — application-owned
export const send = mutation({
args: {
targetUserId: v.string(),
targetDeviceId: v.number(),
senderDeviceId: v.number(),
ciphertext: v.string(),
messageType: v.string(),
timestamp: v.number(),
clientMessageId: v.optional(v.string()),
urgent: v.optional(v.boolean()),
ephemeral: v.optional(v.boolean()),
groupId: v.optional(v.string()),
recipientRegistrationId: v.optional(v.number()),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error('Unauthenticated');
const senderUserId = identity.subject; // never args
// insert into your messages table, return { messageId, serverTimestamp }
},
});Two more rules from the relay contract, both non-negotiable:
- Backend authentication must bind registration, provisioning, unlink, and removal to the owning account.
- The server owns linked-device slot allocation. Clients must not choose linked
deviceIds. Allocate from2through5inside an authenticated mutation. Device1is the primary. Maximum 5 devices per user.
Public-key reads may be available to authenticated peers. Every write must derive ownership from server-side authentication.
Stage 4: one-time-prekey consumption under concurrency
This deserves its own section because it is a correctness bug, not a performance note, and because it is invisible in testing.
Two peers can call keys.fetchPreKeyBundle for the same target device at once. The relay must not return the same unused one-time prekey twice.
A one-time prekey gives PQXDH a per-session secret that no other session shares. If two initiators receive one prekey, both sessions use the same one-time input. The recipient deletes the private half after first use. The second session then fails or uses less independent key material than the protocol expects. This defect weakens forward secrecy without an explicit log signal. A 2025 WhatsApp study found that 13% of companion devices lacked a one-time prekey during the scan.
Convex runs mutations as transactions, so the implementation is short. In one mutation, select an unused prekey row, consume it, and return the bundle. Convex retries a mutation when another transaction invalidates its read set. Two concurrent callers therefore receive different prekeys.
The failure shapes to avoid are the ones that look reasonable in review:
- A query followed by a consume mutation. Two transactions leave a window for a second caller. The SDK therefore types
fetchPreKeyBundleas a mutation. - An action that orchestrates both. Actions are not transactional. Another operation can change a value between an action's read and write.
- Deleting the row only after the caller confirms use. The caller may never confirm. Meanwhile the prekey is still servable.
When no unconsumed one-time prekey exists, return a bundle without one rather than failing. The Kyber last-resort prekey and the signed prekey exist for exactly that case, and a hard failure takes the conversation down instead of degrading it. Then instrument the rate, because degradation is otherwise silent.
The adapter expects the server to derive the fetcher's identity from authentication. It documents a limit of 10 fetches per minute for each fetcher-target pair. Bundle fetch also enables enumeration. Without a limit, an authenticated user can drain another user's prekeys and force later sessions onto the last-resort key.
Stage 5: the rest of the relay's responsibilities
The contract lists six, and each one has a shape your functions have to honour.
Identity and prekey upload. keys.uploadIdentityKey carries mode: 'provision' | 'rotate', compositeIdentity, registrationId, and identityType. The base64 compositeIdentity contains CompositeIdentityV1. Rotation also carries expectedCurrentCommitment, a compare-and-swap value that you must check. keys.uploadPreKeys carries keys: [{ type, keyId, publicKey, signature }]. The type selects ecPreKey, ecSignedPreKey, kemOneTimePreKey, or kemLastResortPreKey as the destination.
Prekey bundle fetch. Covered above. Return deviceId, registrationId, compositeIdentity, signedPreKey, an optional oneTimePreKey, and an optional kyberPreKey.
Device registration and listing. devices.registerDevice allocates the slot. devices.getDevices lists them. Device names arrive as encryptedDeviceName bytes. The device encrypts names for backend storage and decrypts each label before rendering the device list.
Envelope delivery. messages.send, messages.getPendingMessages({ deviceId }), messages.markDelivered({ messageId }). Make send idempotent on clientMessageId so a client retrying after an unknown result gets the original accept metadata instead of a duplicate pending envelope.
Linked-device provisioning state. Eight functions, and one hard limit: provisioning sessions expire after five minutes. Ephemeral ECDH keys derive the provisioning encryption keys. Devices encrypt identity material before it reaches the relay. Provisioning transfers identity: not sessions and not history. Migrating to replacement hardware is a different operation entirely. See Multi-device and Recovery, backup, migration.
Stale-device and unlink cleanup. Must stay consistent across active identity types. keys.clearStaleKemPreKeys exists for the prekey half of it.
What the backend can see
Opacity ledger
| Artifact | Convex table | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|---|
identityKeyPair private half | — | yes | no | n/a | no |
compositeIdentity (public CompositeIdentityV1) | identityKeys | yes | yes | n/a | yes |
registrationId | identityKeys | yes | yes | n/a | yes |
ecSignedPreKey public half + signature | ecSignedPreKeys | yes | yes | n/a | yes |
ecPreKey public halves | ecPreKeys | yes | yes | n/a | count |
kemOneTimePreKey public halves + signatures | kemOneTimePreKeys | yes | yes | n/a | count |
kemLastResortPreKey public half + signature | kemLastResortPreKeys | yes | yes | n/a | yes |
| All prekey private halves | — | yes | no | n/a | no |
Envelope ciphertext | messages | yes | yes | n/a | size |
targetUserId, targetDeviceId, senderDeviceId | messages | yes | yes | n/a | yes |
messageType, timestamp, clientMessageId | messages | yes | yes | n/a | yes |
senderUserId (derived from JWT auth) | messages | n/a | no | n/a | yes |
| Message plaintext | — | yes | no | n/a | no |
deviceId, deviceType | devices | yes | yes | n/a | yes |
encryptedDeviceName | devices | yes | yes | n/a | ciphertext length |
| Plaintext device name | — | yes | no | n/a | no |
Provisioning sessionId and encrypted payload | provisioningSessions | yes | yes | n/a | yes |
| Provisioning ephemeral private key | — | yes | no | n/a | no |
| One-time-prekey consumption record | prekeyBundleFetches | no | yes | n/a | yes |
SessionRecord (version: 4), ratchet state | — | yes | no | n/a | no |
Those nine table names, devices, identityKeys, ecPreKeys, ecSignedPreKeys, kemOneTimePreKeys, kemLastResortPreKeys, messages, provisioningSessions, prekeyBundleFetches, come from the adapter's own type documentation. The SDK does not ship them as a schema. Its types use these nine buckets, and your convex/schema.ts creates them.
The relay never needs message plaintext or device private keys. It does need everything in the "Sent to relay" column, and that column is your metadata exposure: see Limits and metadata.
Failure and recovery behaviour
Production caveats
- We publish the package on npm as
0.1.x: AGPL-3.0-or-later, reviewed continuously by adversarial AI agents, and not audited by any independent firm. Public APIs and persisted formats may change before 1.0. - The Expo store requires a development build and is not available in Expo Go. The bare React Native store needs a key-value backend you supply and verify with the exported backend-conformance kit.
inMemoryRelay()andinMemoryStore()are development only and must not reach production. - Push reads
EXPO_PUBLIC_CONVEX_URLand needsgetAuthToken. Without it, polling adds one function call for each device and interval. - Multi-device cost is multiplicative:
(nA−1)·nBencryptions per message. Five devices per user is not five times the work. - Sealed sender does not compose. Published research: "this one-sided anonymity is broken when two parties send multiple messages back and forth; that is, the promise of sealed sender does not compose over a conversation of messages": linkable in as few as 5 messages. E2EE is not anonymity.
- Rebuild server-side features. Meta rebuilt "well over 100" Messenger features because "A lot of the logic was on the server and that doesn't work anymore." Plan for search, moderation, and notification-content changes.
- Assurance today: 384 modules, 6,893 assertions, 2 skipped, 0 failed, 330 s. Public CI runs
npm ci, build, typecheck, andnpm audit --omit=dev.
Next
- Convex relay integration: the adapter boundary on its own
- Relay and prekeys: the contract any relay must satisfy
- Multi-device: provisioning, slot allocation, and why transfer is a different operation
- Production checklist: what to verify before this carries real traffic
Build a browser E2EE demo with two local identities
Two identities in one page, one real encrypted round trip, and a side-by-side look at what the relay holds versus what the app renders.
Add encrypted attachments with an opaque object store
A complete build for encrypted file transfer through a brokered object store, using either the Convex R2 adapter or the S3 adapter, with the memory and failure behaviour stated plainly.