End-to-end encrypted messaging looks simple until you list the constraints. The server that relays messages must not be able to read them. The recipient is usually offline when the first message is sent, so there is no live handshake. Phones are lost, stolen and restored from backups, so keys will leak, and a single leaked key should expose as little as possible. Messages arrive late, out of order or not at all.
The Signal Protocol answers these constraints with two layers. An asynchronous key agreement, X3DH and now its post-quantum successor PQXDH, sets up a shared secret from keys the recipient published in advance. The Double Ratchet then derives a fresh key for every message and mixes new Diffie-Hellman output into the state on every round trip. This article covers the architecture, keys, both algorithms in illustrative code, a worked exchange and failure modes. Specification details are taken from the current Double Ratchet (revision 4, November 2025) and PQXDH documents on signal.org.
The goals, stated precisely
Four properties drive the design. Confidentiality and authentication: only the two endpoints can read a message, and each knows who the other is. Forward secrecy: stealing a device's current state must not decrypt earlier messages. Post-compromise security, or healing: after an attacker copies the state once and loses access, future messages become secret again. Asynchrony: all of this works while the recipient is offline.
The server is assumed hostile: it can read, drop, delay, reorder and replay anything it relays, and it controls the public-key directory. It must not gain message content or undetectable impersonation, and the latter depends on users verifying identity keys through safety numbers. Write this threat model down first; threat modelling is where most E2E designs quietly go wrong.
System architecture: what the server does and does not hold
The server side is a key directory, a store-and-forward queue and a push integration. Each device uploads a bundle of public keys. A sender fetches the bundle, computes a session key locally and posts ciphertext addressed to a device. The queue holds that ciphertext until the recipient connects, and the push service carries only a wake-up signal. The queuing, fan-out and connection handling are ordinary messaging infrastructure; designing a real-time chat system covers that half. The consequence for operators is that the server cannot recover a lost session, re-encrypt for a new device or search content; those features must be redesigned around keys that exist only on clients.
The key hierarchy
Signal uses several kinds of keys with very different lifetimes. The short-lived ones provide forward secrecy; the long-lived identity key provides authentication.
| Key | Lifetime | Where it lives | Purpose |
|---|---|---|---|
| Identity key IK | Life of the install | Private on device, public in directory | Long-term identity; verified through safety numbers |
| Signed prekey SPK | Rotated periodically | Public in directory, signed by IK | Lets offline recipients take part in key agreement |
| One-time prekeys OPK | Used once, then deleted | Batch uploaded to directory | Extra DH input per session; limits replay |
| Post-quantum prekey | Signed; one-time or last-resort | Public in directory | KEM public key used in PQXDH |
| Ephemeral key EK | One session setup | Sender's device | Fresh randomness from the initiator |
| Ratchet key pair | One ratchet turn | Session state, public part in headers | Drives the DH ratchet |
| Root, chain, message keys | Seconds to one message | Session state | Symmetric key derivation; message keys are deleted after use |
Session setup: from X3DH to PQXDH
Bob publishes a prekey bundle: his identity key, a signed prekey with its signature, a signed post-quantum KEM prekey and, if any remain, a one-time curve prekey. Alice fetches the bundle, verifies the signatures, generates an ephemeral key and computes several Diffie-Hellman values. Each covers a different property: DH1 binds Alice's identity, DH2 binds Bob's identity, DH3 and DH4 bring in fresh ephemeral randomness so that compromising identity keys later does not reveal the session. PQXDH adds a KEM encapsulation against Bob's post-quantum prekey. The resulting secret SS is fed into the same KDF, so an attacker must break both the curve and the KEM. The PQXDH specification names Crystals-Kyber-1024 as its example KEM.
# Illustrative only: use libsignal, never a hand-rolled implementation.
def pqxdh_initiate(alice_ik, bundle):
verify_sig(bundle.ik, bundle.spk.public, bundle.spk_sig) # abort on failure
verify_sig(bundle.ik, bundle.pqpk.public, bundle.pqpk_sig)
ek = x25519_generate()
dh1 = dh(alice_ik.private, bundle.spk.public) # Alice's identity
dh2 = dh(ek.private, bundle.ik.public) # Bob's identity
dh3 = dh(ek.private, bundle.spk.public) # freshness
dhs = dh1 + dh2 + dh3
if bundle.opk is not None:
dhs += dh(ek.private, bundle.opk.public) # DH4: one-time prekey
ct, ss = kem_encapsulate(bundle.pqpk.public) # post-quantum shared secret
sk = kdf(dhs + ss)
ad = encode(alice_ik.public) + encode(bundle.ik.public) # bound into every AEAD call
return sk, ad, InitialHeader(alice_ik.public, ek.public, bundle.ids, ct)Alice sends her first message immediately, encrypted under a key derived from SK, together with a header naming which prekeys she used. When Bob comes online he repeats the computation with his private keys, deletes the one-time prekey and the session is established. If the directory has run out of one-time prekeys, the protocol still works without DH4, but replay protection for that first message is weaker. That is why clients top up their prekey supply.
The Double Ratchet
The Double Ratchet keeps three KDF chains per session: a root chain, a sending chain and a receiving chain. A KDF chain is a one-way function applied repeatedly. Knowing a link lets you compute the links after it, never the ones before. That one-way property is what gives forward secrecy.
The symmetric-key ratchet advances a chain once per message; the specification recommends HMAC with the constant 0x01 for the message key and 0x02 for the next chain key. The Diffie-Hellman ratchet provides healing. Every header carries the sender's current ratchet public key, and on receiving a new one a party performs two DH computations, feeds them through the root KDF (HKDF, with the root key as salt and the DH output as input key material) and starts fresh chains. An attacker who stole the old chain keys cannot follow, because the new DH output depends on a private key generated after the theft.
# Illustrative pseudocode following the structure of the Double Ratchet specification.
def kdf_ck(ck):
return hmac_sha256(ck, b"\x02"), hmac_sha256(ck, b"\x01") # (next chain key, message key)
def kdf_rk(rk, dh_out):
okm = hkdf_sha256(salt=rk, ikm=dh_out, info=b"MyApp-Ratchet", length=64)
return okm[:32], okm[32:] # (new root key, new chain key)
def ratchet_encrypt(s, plaintext, ad):
s.cks, mk = kdf_ck(s.cks)
header = Header(dh=s.dhs.public, pn=s.pn, n=s.ns)
s.ns += 1
return header, aead_encrypt(mk, plaintext, ad + encode(header))
def ratchet_decrypt(s, header, ciphertext, ad):
if (header.dh, header.n) in s.skipped: # late message
mk = s.skipped.pop((header.dh, header.n))
return aead_decrypt(mk, ciphertext, ad + encode(header))
if header.dh != s.dhr: # peer turned the ratchet
skip_message_keys(s, header.pn) # finish the old chain
dh_ratchet(s, header)
skip_message_keys(s, header.n)
s.ckr, mk = kdf_ck(s.ckr)
s.nr += 1
return aead_decrypt(mk, ciphertext, ad + encode(header))
def dh_ratchet(s, header):
s.pn, s.ns, s.nr = s.ns, 0, 0
s.dhr = header.dh
s.rk, s.ckr = kdf_rk(s.rk, dh(s.dhs.private, s.dhr)) # new receiving chain
s.dhs = x25519_generate()
s.rk, s.cks = kdf_rk(s.rk, dh(s.dhs.private, s.dhr)) # new sending chainThe header's three fields do the bookkeeping: the ratchet public key, PN (how many messages were sent on the previous sending chain) and N (this message's number in the current chain). For the AEAD, the specification recommends deriving 80 bytes with HKDF from the message key and using AES-256-CBC with HMAC over the associated data and ciphertext. A header-encryption variant also exists, which hides ratchet keys and counters from the server.
Out-of-order delivery and MAX_SKIP
If message 3 of a chain arrives before message 2, the receiver advances the chain past 2, stores message key 2 in a skipped-keys table and decrypts 3. When 2 arrives it is decrypted from the table and that key is deleted. PN handles the case where a whole chain ended before its last messages arrived.
The table is a denial-of-service surface: a header claiming N = 10,000,000 would force ten million HMACs. The specification bounds skipping with a constant, MAX_SKIP, high enough to tolerate routine loss and low enough to bound the work. Implementations also expire stored skipped keys, because a key kept forever weakens forward secrecy for its message.
A worked exchange
- Bob's device registers: it uploads IK_B, a signed SPK_B, a signed post-quantum prekey and 100 one-time prekeys.
- Alice, offline from Bob, fetches his bundle; the server hands out OPK_B #37 and deletes it from the directory. Alice runs PQXDH, gets SK, initialises her ratchet with Bob's signed prekey as his first ratchet key and sends messages A1 and A2 on her first sending chain.
- Bob comes online, reads the initial header, recomputes SK, deletes OPK #37's private key and decrypts A1 and A2. Two message keys were used and deleted.
- Bob replies with B1 under a new ratchet key, and Alice ratchets on receipt. Once Alice sends on her own new ratchet key, an attacker who copied her state before step 2 can no longer derive current keys.
- Alice sends A3 and A4, but the network delivers A4 first. Bob's receiving chain skips A3, stores its key and decrypts A4. A3 arrives a second later and is decrypted from the skipped-keys table.
- Bob's phone is seized a week later. The attacker gets the current state, but earlier message keys were deleted and the chains cannot run backwards, so older messages stay unreadable.
Post-quantum: PQXDH and the Triple Ratchet
Classical Diffie-Hellman falls to a large quantum computer, so ciphertext recorded today could be decrypted later. PQXDH, deployed in 2023, protects session setup against that attack, but not the ratchet's healing.
In October 2025 Signal announced the Sparse Post-Quantum Ratchet (SPQR), which runs alongside the Double Ratchet; the combination is called the Triple Ratchet. Keys from both ratchets are mixed through a KDF, so a message stays safe unless both the elliptic-curve and the ML-KEM-768 layers are broken. The engineering problem is size. An ML-KEM-768 encapsulation key plus a ciphertext is 2,272 bytes, against 32 bytes for an X25519 public key, and putting that in every header would be expensive. SPQR spreads it over many messages in erasure-coded chunks, so the peer can reassemble it from any sufficient subset even if some messages are lost. That is why the ratchet is 'sparse': post-quantum healing happens every few messages rather than on every turn.
Multi-device, groups and identity
A session is between two devices, not two people. A message to a user with a phone and a laptop is encrypted once per device session; Signal's Sesame specification describes how clients keep that per-device state consistent. The directory says which devices exist, which is exactly where a hostile server could add its own. The defence is safety-number comparison and a visible warning when a contact's identity key changes.
Groups multiply the cost: pairwise encryption scales with members times devices. The common alternative is sender keys: each member distributes a symmetric chain over pairwise sessions and then encrypts each group message once. The trade-off is weaker healing, because a sender key chain has no DH ratchet of its own.
Failure modes and operational guidance
| Failure | What users see | Mitigation |
|---|---|---|
| State restored from an old backup | Peers' messages fail to decrypt; ratchet state is behind | Never back up ratchet state naively; reset the session and show a notice |
| One-time prekeys exhausted | Sessions start without DH4 | Clients replenish when the server reports a low count; alert on depletion rate |
| Identity key change | Safety-number warning | Make the warning clear; do not auto-accept changes silently |
| Unbounded skipped keys | Memory growth, slow decrypts | Enforce MAX_SKIP and expire stored keys |
| Push payload leaks metadata | Contents safe, but who-talks-to-whom exposed | Content-free wake-ups; minimise stored logs |
| Hand-rolled crypto | Nonce or key reuse, silent breakage | Use libsignal; test against known vectors |
Two operational rules follow. First, server metadata, meaning IP addresses, timestamps and contact graphs, is your real privacy exposure, so keep retention minimal. Second, keep transport security independent of the E2E layer: certificate pinning protects metadata and the key-directory channel from a network attacker. Server-side keys at rest, such as those protecting account records, belong in envelope encryption with a KMS, not in the E2E design.
Trade-offs to decide explicitly
- Forward secrecy against history: deleted message keys make history sync hard, and any backup is a second encryption system.
- Pairwise fan-out against sender keys: pairwise gives the strongest healing, sender keys cost less in large groups.
- Header encryption against debuggability: hiding counters and ratchet keys from the server makes delivery problems harder to diagnose.
- Post-quantum cost against bandwidth: hybrid KEMs add kilobytes per exchange; sparse schemes spread that cost at the price of slower post-quantum healing.
What to do next
- Write the threat model: what the server may see, who can add devices and what a stolen phone must not reveal.
- Adopt libsignal or another audited implementation; treat the pseudocode here as a reading aid, not a design.
- Monitor prekey depletion, skipped-key table sizes and decryption-failure rates per client version.
- Design identity-key-change and new-device flows with product and UX, including safety-number verification.
- Inventory server metadata and cut retention to the minimum delivery requires.
- Plan the post-quantum path: PQXDH for session setup first, then a hybrid ratchet if your threat model includes long-lived secrets.