This document describes the backend E2EE onboarding flow implemented today for device registration and prekey upload, and how a client should sequence first-contact DM setup on top of those APIs.
It covers:
- happy path
- offline-recipient path
- prekey-exhausted path
It also records the exact request/response JSON shapes implemented by the current backend endpoints and the ordering guarantees clients can rely on.
The currently implemented backend endpoints involved in onboarding are:
POST /auth/challengePOST /auth/verifyGET /devicesPOST /devices/:id/prekeysGET /users/:userId/devices/:deviceId/key-bundle— fetches another user's device bundle (identity key + signed prekey + one atomically-consumed one-time prekey)
There is currently no implemented backend endpoint in this repo for:
- server-side session creation
- sending encrypted DM envelopes
Those later steps are part of the intended product flow, but they are not exposed by implemented backend routes in apps/backend/src/routes yet. This doc therefore does two things:
- documents the exact JSON that exists now for onboarding/device-prekey registration
- documents the required sequencing and guarantees for first-DM bundle fetch/session/envelope send so future endpoints preserve compatibility with the existing implementation
- Sender client: the device initiating onboarding or first DM
- Recipient client: the target device/user for a first DM
- Backend API: Express app in
apps/backend/src/routes
The implemented E2EE-related tables are defined in apps/backend/src/db/schema.ts.
A device row is created during POST /auth/verify keyed by (userId, identityPublicKey).
Stored shape:
{
"id": "uuid",
"userId": "uuid",
"identityPublicKey": "base64-ed25519-spki-der",
"isRevoked": false,
"createdAt": "timestamp",
"updatedAt": "timestamp"
}Notes:
identityPublicKeyis the long-term device identity public key.- device lookup during verify is by
userId + identityPublicKey - revoked devices cannot sign in again
Exactly one signed prekey is stored per device.
Stored shape:
{
"id": "uuid",
"deviceId": "uuid",
"keyId": 1,
"publicKey": "base64",
"signature": "base64",
"createdAt": "timestamp"
}Notes:
- uniqueness is enforced on
deviceId - uploading replaces the previous signed prekey for that device
Stored shape:
{
"id": "uuid",
"deviceId": "uuid",
"keyId": 10,
"publicKey": "base64",
"createdAt": "timestamp"
}Notes:
- uniqueness is enforced on
(deviceId, keyId) - max stored one-time prekeys per device is
200 - duplicate uploads are ignored on conflict
Endpoint:
POST /auth/challenge
Request JSON:
{
"walletAddress": "G..."
}Response JSON:
{
"message": "Sign in to Clicked\nWallet: G...\nNonce: abc123",
"nonce": "abc123"
}Ordering guarantees:
- client must call this before
POST /auth/verify - returned
noncemust be echoed unchanged toPOST /auth/verify - the backend consumes the nonce during verify, so the same nonce is single-use from the client's perspective
Endpoint:
POST /auth/verify
Request JSON:
{
"walletAddress": "G...",
"signature": "signature-string",
"nonce": "abc123",
"identityPublicKey": "base64-ed25519-spki-der"
}Success response JSON:
{
"token": "jwt"
}Possible error JSON:
{ "error": "Invalid or expired nonce" }{ "error": "Signature verification failed" }{ "error": "Invalid signature or wallet address" }{ "error": "Device has been revoked" }{ "error": "Failed to create user" }{ "error": "Failed to register device" }Behavior and guarantees:
- nonce is validated and consumed first
- wallet signature is verified second
- user and wallet are resolved/upserted third
- device is resolved by
(userId, identityPublicKey)fourth - if no device exists, a new device row is inserted
- returned JWT includes the backend device row id as
deviceId
Important ordering guarantee:
- a client must not attempt
POST /devices/:id/prekeysuntil it has successfully completedPOST /auth/verifyand extracted the authenticated device id from the returned JWT context
Clicked applies replay defenses at three layers so retries stay safe while stale or duplicated payloads are rejected:
- Auth nonce (
POST /auth/challenge→POST /auth/verify)- each challenge nonce is bound to the wallet address that requested it
- the nonce is single-use and expires after 5 minutes
POST /auth/verifyconsumes the nonce before signature verification, so a captured auth payload cannot be replayed after the first successful submit
- Socket dispatch envelopes (
dispatch)- every envelope must include a unique
eventIdand a clienttimestamp - the backend stores each accepted
eventIdfor 24 hours and drops later duplicates without re-running the handler - the backend rejects envelopes older than 5 minutes or more than 30 seconds in the future to narrow the replay window for intercepted payloads
- every envelope must include a unique
- Message persistence (
messageId)POST /messages,send_message,edit_message, andsend_file_messagerequire a client-generatedmessageId- if the same
messageIdarrives again, the backend treats it as an idempotent retry and returns the original ack/created timestamp instead of inserting a duplicate row
Operational guidance:
- retries must reuse the original
eventId/messageId - new user actions must generate fresh ids
- client clocks should stay reasonably accurate; overly stale or future-dated dispatch envelopes are rejected even if their signature/auth data is valid
POST /auth/verify returns only:
{
"token": "jwt"
}The backend signs the token with payload fields including:
{
"userId": "uuid",
"walletAddress": "G...",
"deviceId": "uuid"
}So the authenticated device id used in subsequent calls is the deviceId embedded in the JWT.
Endpoint:
POST /devices/:id/prekeys
Auth:
Authorization: Bearer <jwt-from-auth-verify>
Path parameter:
:idmust be the authenticated backend device row id from the JWT
Request JSON:
{
"signedPreKey": {
"keyId": 1,
"publicKey": "base64",
"signature": "base64"
},
"oneTimePreKeys": [
{
"keyId": 10,
"publicKey": "base64"
},
{
"keyId": 11,
"publicKey": "base64"
}
]
}Success response JSON:
{
"uploadedSignedPreKey": true,
"uploadedOneTimePreKeys": 2,
"capped": false
}Success response when batch is trimmed by the cap:
{
"uploadedSignedPreKey": true,
"uploadedOneTimePreKeys": 1,
"capped": true
}Possible error JSON:
{ "error": "Device not found" }{ "error": "Only the device owner may upload prekeys" }{ "error": "Device is revoked" }{ "error": "Signed prekey signature is invalid" }{ "error": "One-time prekey cap of 200 reached. Consume existing prekeys before uploading more." }Validation constraints:
signedPreKey.keyIdis a non-negative integersignedPreKey.publicKeyis requiredsignedPreKey.signatureis requiredoneTimePreKeysmust contain at least one item- each one-time prekey must include non-negative integer
keyIdand requiredpublicKey
Behavior and guarantees:
- authenticated caller is checked first
- referenced device row is loaded second
- ownership is enforced third
- revoked devices are rejected fourth
- signed prekey signature is verified against
device.identityPublicKeyfifth - current one-time prekey count is checked sixth
- signed prekey upsert runs before one-time prekey inserts
- one-time prekeys are inserted with conflict-ignore semantics
- response reports how many one-time prekeys from the incoming batch were accepted after cap trimming
Important ordering guarantees:
- signed prekey upload and one-time prekey upload happen in a single request
- the device's signed prekey is always written before any one-time prekey insert in handler order
- if the device already has
200one-time prekeys stored, the request is rejected with422and nothing new is uploaded - if the incoming batch would exceed the cap, the server trims the batch to fit the remaining slots instead of rejecting the whole request
- duplicate one-time prekeys by
(deviceId, keyId)are ignored by the database conflict rule
This is the intended first-time onboarding and first-DM path.
Client Backend Recipient state
| | |
|-- POST /auth/challenge -------->| |
|<- { message, nonce } -----------| |
| | |
|-- sign challenge locally -------| |
| | |
|-- POST /auth/verify ----------->| |
| { walletAddress, signature, | |
| nonce, identityPublicKey } | |
|<- { token } --------------------| |
| | |
|-- derive deviceId from JWT -----| |
| | |
|-- POST /devices/:id/prekeys --->| |
| { signedPreKey, | |
| oneTimePreKeys[] } | |
|<- { uploadedSignedPreKey, | |
| uploadedOneTimePreKeys, | |
| capped } -------------------| |
| | |
|-- GET /users/:uid/devices/ ---->| resolves via #133 fanout |
| :did/key-bundle | for the full device set |
|<- recipient bundle -------------| |
| | |
|-- establish session locally ----| |
| | |
|-- send encrypted envelopes ---->| DM send path separate |
| | |
GET /users/:userId/devices/:deviceId/key-bundle returns one device's bundle per
call (callers loop over the recipient's active device list — see
GET /conversations/:id/devices — to build an envelope per device):
{
"deviceId": "uuid",
"identityPublicKey": "base64-ed25519-spki-der",
"registrationId": 1234,
"signedPreKey": {
"keyId": 1,
"publicKey": "base64",
"signature": "base64"
},
"oneTimePreKey": {
"keyId": 10,
"publicKey": "base64"
}
}:userId must match the target device's actual owner or the endpoint returns
404 — this route intentionally cannot be used to enumerate another user's
devices without already knowing both ids.
Client expectations for the happy path:
- fetch each recipient device's bundle after sender has uploaded its own prekeys
- use recipient
identityPublicKey - verify recipient
signedPreKey.signatureagainst recipientidentityPublicKey - use a consumed recipient
oneTimePreKeyif present - establish the initial session locally
- encrypt one envelope per recipient device and send them through the messaging path
In the offline-recipient path, the recipient client is not connected, but has already onboarded and uploaded prekeys.
Sender client Backend Recipient client
| | |
|-- fetch recipient bundle ------>| |
|<- bundle with signedPreKey -----| |
| and oneTimePreKey | |
| | |
|-- establish session locally ----| |
| | |
|-- send encrypted envelopes ---->|---- stores/queues envelopes ----->|
| | |
| |<--- recipient later comes online --|
| |---- recipient receives envelope -->|
Required guarantees for this path:
- recipient need not be online if the backend can return a valid bundle with at least identity key + signed prekey
- bundle fetch must reserve or consume at most one one-time prekey per recipient device for the session-init message
- envelope send must be durable enough for later delivery
How this maps to the implemented code today:
- the preconditions for offline delivery already exist: device identity keys, one signed prekey per device, and a stock of one-time prekeys per device
- the bundle-fetch route (
GET /users/:userId/devices/:deviceId/key-bundle) is implemented; envelope storage/queueing for offline recipients is handled by the message-send + sync path (GET /sync), documented separately
This path happens when the recipient has no one-time prekeys left, or when a sender-side device has reached the upload cap and needs replenishment handling.
This part is implemented today.
Client Backend
| |
|-- POST /devices/:id/prekeys --->|
| |
|<- 422 { error: "One-time -|
| prekey cap of 200 |
| reached..." } |
Client behavior:
- do not retry the same upload blindly
- wait until existing one-time prekeys have been consumed, or rotate strategy client-side
- if partial capacity remains, respect
uploadedOneTimePreKeysandcapped: true
This recipient-side fetch path is implemented — GET /users/:userId/devices/:deviceId/key-bundle
returns oneTimePreKey: null (rather than erroring) once a device's one-time
prekeys are exhausted, so the sender falls back to a 3-DH session:
Sender client Backend
| |
|-- GET .../key-bundle ---------->|
|<- bundle with identity key -----|
| + signed prekey only |
| + oneTimePreKey: null |
| |
|-- establish fallback session ---|
| using signed prekey only |
| |
|-- send prekey envelope -------->|
Actual JSON shape for the prekey-exhausted bundle response:
{
"deviceId": "uuid",
"identityPublicKey": "base64-ed25519-spki-der",
"registrationId": 1234,
"signedPreKey": {
"keyId": 1,
"publicKey": "base64",
"signature": "base64"
},
"oneTimePreKey": null
}Required guarantees for this path:
- absence of a one-time prekey must be explicit, not ambiguous
- signed prekey must still be present if the device remains reachable for session bootstrap
- client must treat this as lower-entropy/fallback first-contact establishment and should trigger recipient prekey replenishment UX when possible
Waiting for exhaustion means every sender in the meantime is downgraded to 3-DH, so the backend warns the owning device before it runs dry.
Two surfaces expose this:
GET /devicesreturnsoneTimePreKeysRemainingper device — a count of unconsumed one-time prekeys,0when the device has none. Poll-free clients can read this at startup to decide whether to top up.- A
prekeys_lowSocket.IO event is emitted after a bundle fetch drops a device below the threshold (default20, overridable with thePREKEY_LOW_THRESHOLDenv var).
Event payload:
{
"deviceId": "uuid",
"oneTimePreKeysRemaining": 19,
"threshold": 20
}Delivery and debounce semantics:
- emitted only to the
device:{deviceId}room — the owning device, on whichever gateway holds its socket. No other device on the account sees it, since only the owner can generate replacement prekeys. - fired at most once per threshold crossing. A device that keeps serving bundles while below the threshold is told once, not once per fetch.
- the signal re-arms when
POST /devices/:id/prekeysbrings the device back to or above the threshold, so a later crossing warns again. Revoking a device also re-arms it (its prekeys are deleted). - a device that is offline when the threshold is crossed misses the event; it
should read
oneTimePreKeysRemainingfromGET /deviceson reconnect.
Client behavior:
- on
prekeys_low, generate and upload a fresh batch viaPOST /devices/:id/prekeys, respecting the200cap - the upload response echoes
oneTimePreKeysRemainingso the client can confirm it is back above the threshold without a follow-upGET /devices
For compatibility with the current implementation, clients should rely on this ordering:
- generate local identity keypair
- call
POST /auth/challenge - sign the challenge message with the wallet
- call
POST /auth/verifywithidentityPublicKey - receive JWT containing backend
deviceId - call
POST /devices/:deviceId/prekeys - only after successful prekey upload, attempt first-DM recipient bundle fetch via
GET /users/:userId/devices/:deviceId/key-bundle - establish session locally from recipient bundle
- send encrypted envelope(s)
- a device cannot upload prekeys before it has authenticated and been resolved to a backend device id
- a revoked device cannot authenticate or upload prekeys
- a signed prekey is validated against the stored device identity key before it is accepted
- one-time prekeys are capped at 200 stored keys per device
- prekey upload returns the accepted count so the client can reconcile local inventory
- auth challenge/verify:
apps/backend/src/routes/auth.ts - auth request schema:
apps/backend/src/schemas/auth.schemas.ts - device registration/listing/revocation/prekey upload:
apps/backend/src/routes/devices.ts - recipient key-bundle fetch:
apps/backend/src/routes/users.ts(GET /users/:userId/devices/:deviceId/key-bundle) - E2EE-related schema:
apps/backend/src/db/schema.ts(devices,devicePrekeys) - low-prekey signal + debounce latch:
apps/backend/src/services/prekeyLowSignal.ts - prekey route tests:
apps/backend/src/__tests__/devices.prekeys.test.ts - key-bundle route tests:
apps/backend/src/__tests__/users.bundle.test.ts - low-prekey signal tests:
apps/backend/src/__tests__/prekeysLow.test.ts
Recipient bundle fetch and atomic one-time prekey consumption are implemented (see above). Backend work still needed for full first-DM support:
- encrypted envelope submit/store/deliver for the first contact between two
users specifically (the general send path exists — see
docs/for the message/envelope model — but hasn't been audited end-to-end against this onboarding sequence) - explicit multi-device fanout semantics for first-contact DM
This document is written so that work can build on the already-implemented onboarding JSON and ordering contract without changing it.