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.

Advertisement

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

Signal architecture: clients hold every secret, the server stores and forwardsAlice's deviceidentity key, sessionsBob's deviceidentity key, prekeysKey directorypublic prekey bundlesMessage queueopaque ciphertext per devicePush servicewake-up only, no contentupload bundlefetch bundleciphertextdeliverSession state on each device (never uploaded)root key, sending chain key, receiving chain keys, current ratchet key pair, skipped message keysPQXDH: first message3-4 DH outputs + a KEM secret -> SKDouble Ratchet (+ SPQR since 2025)new keys per message and per round trip
The server has three jobs: publish public prekey bundles, queue opaque ciphertext per device and send content-free push wake-ups. Every secret and all session state stays on the devices.

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.

Advertisement

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.

KeyLifetimeWhere it livesPurpose
Identity key IKLife of the installPrivate on device, public in directoryLong-term identity; verified through safety numbers
Signed prekey SPKRotated periodicallyPublic in directory, signed by IKLets offline recipients take part in key agreement
One-time prekeys OPKUsed once, then deletedBatch uploaded to directoryExtra DH input per session; limits replay
Post-quantum prekeySigned; one-time or last-resortPublic in directoryKEM public key used in PQXDH
Ephemeral key EKOne session setupSender's deviceFresh randomness from the initiator
Ratchet key pairOne ratchet turnSession state, public part in headersDrives the DH ratchet
Root, chain, message keysSeconds to one messageSession stateSymmetric 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

One DH ratchet step feeds a root KDF chain, which seeds a symmetric chain per directionRoot key RK0Root key RK1Root key RK2DH(a1, B1)DH(a2, B1)Receiving chain CKBob -> AliceSending chain CKAlice -> BobMK 0MK 1MK 0MK 1CK_next = HMAC(CK, 0x02), MK = HMAC(CK, 0x01)
Three KDF chains. The root chain advances on every DH ratchet step; each step starts a new sending or receiving chain, and every message key is one link of a symmetric chain.

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 chain

The 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

  1. Bob's device registers: it uploads IK_B, a signed SPK_B, a signed post-quantum prekey and 100 one-time prekeys.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

FailureWhat users seeMitigation
State restored from an old backupPeers' messages fail to decrypt; ratchet state is behindNever back up ratchet state naively; reset the session and show a notice
One-time prekeys exhaustedSessions start without DH4Clients replenish when the server reports a low count; alert on depletion rate
Identity key changeSafety-number warningMake the warning clear; do not auto-accept changes silently
Unbounded skipped keysMemory growth, slow decryptsEnforce MAX_SKIP and expire stored keys
Push payload leaks metadataContents safe, but who-talks-to-whom exposedContent-free wake-ups; minimise stored logs
Hand-rolled cryptoNonce or key reuse, silent breakageUse 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

  1. Write the threat model: what the server may see, who can add devices and what a stolen phone must not reveal.
  2. Adopt libsignal or another audited implementation; treat the pseudocode here as a reading aid, not a design.
  3. Monitor prekey depletion, skipped-key table sizes and decryption-failure rates per client version.
  4. Design identity-key-change and new-device flows with product and UX, including safety-number verification.
  5. Inventory server metadata and cut retention to the minimum delivery requires.
  6. Plan the post-quantum path: PQXDH for session setup first, then a hybrid ratchet if your threat model includes long-lived secrets.
Key takeaway: The Signal Protocol keeps every secret on devices and treats the server as an untrusted relay. PQXDH builds a session from prekeys the recipient published in advance, combining several Diffie-Hellman outputs with a post-quantum KEM secret. The Double Ratchet derives a new key for every message through one-way KDF chains, which gives forward secrecy, and mixes fresh DH output on every round trip, which heals the session after a compromise. Since 2025 a sparse ML-KEM ratchet runs alongside it. What remains is engineering: bounding skipped keys, replenishing prekeys, handling identity changes and minimising metadata.