DEV Community

Cover image for I Built an End-to-End Encrypted Messenger with Spring Boot and WebCrypto
Evgeny
Evgeny

Posted on

I Built an End-to-End Encrypted Messenger with Spring Boot and WebCrypto

Most chat pet projects follow the same path: authentication, user search, a messages table, WebSocket delivery, and a React UI.

That is a good way to learn realtime development, but it has one architectural problem:

the server knows everything.

It knows the message text.

It can read the history.

It can return the last message in a chat list.

It can log private conversations by accident.

It can expose message content through an admin panel, a SQL query, a bug, or a compromised backend.

I wanted to build something different.

Not just another WebSocket chat, but a messenger where the backend is not a trusted party.

The rule was simple:

the browser encrypts, the server routes, the database stores ciphertext.

That project became Chaos Messenger — a full-stack end-to-end encrypted messenger built with Spring Boot 3, React, WebSocket/STOMP, PostgreSQL, Redis, and WebCrypto API.

Repository: github.com/vaazhen/chaos-messenger

Tech stack: Spring Boot 3, React 18, WebCrypto API, PostgreSQL, Redis, WebSocket/STOMP, Prometheus, Grafana.


A short note about web-based E2EE

When I say that the server cannot read messages, I mean the backend, database, WebSocket layer, logs, and already stored ciphertext envelopes. They do not receive plaintext or private keys.

But web-based E2EE has an important limitation: the frontend code itself is served by a server.

In theory, a compromised server could ship modified JavaScript that steals keys or plaintext before encryption happens. This is not a problem specific to this project. It is a general limitation of browser-based encrypted applications.

So the correct statement is:

the backend does not receive message keys and cannot decrypt already transmitted or stored messages.

Protecting against malicious client-code delivery is a separate security layer: signed builds, independent client verification, desktop/mobile apps, reproducible builds, and platform-level key storage.


Why the usual chat architecture is not enough

Most GitHub messenger projects have some variation of this:

message.setContent(request.getText());
messageRepository.save(message);
Enter fullscreen mode Exit fullscreen mode

This is easy to build and easy to reason about.

But the server owns the message.

If the database leaks, the conversation leaks.

If the backend is compromised, the conversation leaks.

If logging is misconfigured, the conversation leaks.

If an admin panel exposes the wrong field, the conversation leaks.

End-to-end encryption changes the boundary.

The message is encrypted on the sender's device before it is sent over the network. It is decrypted only on the recipient's device. The backend receives an opaque encrypted envelope and routes it to the right device.

This is no longer a privacy policy promise like "we do not read your messages".

It is an architectural restriction:

if the server does not have the key, it cannot turn ciphertext back into plaintext.


The main idea: encrypted envelopes

In the database, the message itself looks intentionally boring:

messages.content = '[encrypted]' -- the server does not know what is inside
message_envelopes.ciphertext = 'qzgHSg7zbwU6h8j8...' -- AES-GCM ciphertext
Enter fullscreen mode Exit fullscreen mode

And this is what the backend returns when the chat list is requested:

{
  "chatId": 32,
  "lastMessage": "[encrypted]",
  "lastMessageAt": "2026-04-28T22:27:35.537016"
}
Enter fullscreen mode Exit fullscreen mode

Not ***.

Not [hidden].

Literally [encrypted].

Because the server has no other value to return.

This design looks simple, but it forces you to rethink a lot of normal backend habits. I will return to this later when we get to the "last message preview" bug.


Where the keys come from: X3DH

The first real question is:

how do Alice and Bob get a shared secret if they have never talked before?

For that I used X3DH — Extended Triple Diffie-Hellman, a key agreement protocol from the Signal ecosystem.

The server stores public key material for each device. The private parts stay on the client.

Device key bundle

When a device is registered, the browser creates a local key bundle:

// crypto-engine.js — key generation during device registration
async function buildNewDeviceBundle() {
    const identity     = await generateX25519KeyPair(); // long-term device key
    const signedPreKey = await generateX25519KeyPair(); // should be rotated periodically
    const oneTimePreKeys = [];

    for (let i = 0; i < 50; i++) {
        const kp = await generateX25519KeyPair();
        oneTimePreKeys.push({
            preKeyId: 1000 + i,
            publicKey: await exportRawPublicKey(kp.publicKey),
            privateKeyPkcs8: await exportPkcs8PrivateKey(kp.privateKey)
        });
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Only public keys are uploaded to the backend.

Private keys remain local to the device and are never sent to the server.

There is also an honest security tradeoff here: in the current version, private keys are serializable and stored locally by the web client. This is not the ideal model. It is vulnerable to XSS and malicious client-side JavaScript.

For a browser project, the usual options are:

  • serializable keys in local storage — simpler, but weaker against XSS;
  • non-extractable WebCrypto keys with IndexedDB — better, but more complex;
  • Android Keystore / iOS Secure Enclave — stronger, but requires native clients.

The current implementation chooses the first option as a practical open-source project compromise. Moving to non-extractable keys is on the roadmap.

Session setup

When Alice wants to start a conversation with Bob, she fetches Bob's prekey bundle from the server and runs X3DH locally.

// crypto-engine.js — X3DH from the initiator side
async function createInitiatorSessionWrapped(localBundle, targetDevice) {
    const identityPrivate       = await importPkcs8PrivateKey(localBundle.identity.privateKeyPkcs8);
    const ephemeral             = await generateX25519KeyPair();
    const remoteIdentityPub     = await importRawPublicKey(targetDevice.identityPublicKey);
    const remoteSignedPreKeyPub = await importRawPublicKey(targetDevice.signedPreKey.publicKey);

    const dh1 = await derive32(identityPrivate,      remoteSignedPreKeyPub); // IK_alice · SPK_bob
    const dh2 = await derive32(ephemeral.privateKey, remoteIdentityPub);     // EK_alice · IK_bob
    const dh3 = await derive32(ephemeral.privateKey, remoteSignedPreKeyPub); // EK_alice · SPK_bob

    const parts = [dh1, dh2, dh3];

    if (remoteOneTimePub) {
        const dh4 = await derive32(ephemeral.privateKey, remoteOneTimePub);  // EK_alice · OPK_bob
        parts.push(dh4);
    }

    const combined = concat(...parts);
    const { rootKey, chainKey } = await deriveRootAndChainKey(combined);

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Bob performs the corresponding operations with his private keys and gets the same shared material.

The server only sees public keys. It cannot compute the shared secret from them.


How every message gets its own key: Symmetric Ratchet

After the initial shared secret is established, every message is encrypted with a unique message key.

The current implementation uses a symmetric ratchet:

// crypto-engine.js — one ratchet step
async function ratchetStep(chainKeyBytes) {
    const key = await crypto.subtle.importKey(
        'raw',
        chainKeyBytes,
        { name: 'HMAC', hash: 'SHA-256' },
        false,
        ['sign']
    );

    const mkBits = await crypto.subtle.sign(
        'HMAC',
        key,
        new Uint8Array([0x01])
    ); // messageKey

    const ckBits = await crypto.subtle.sign(
        'HMAC',
        key,
        new Uint8Array([0x02])
    ); // nextChainKey

    const messageKey = await crypto.subtle.importKey(
        'raw',
        new Uint8Array(mkBits),
        { name: 'AES-GCM' },
        false,
        ['encrypt', 'decrypt']
    );

    return {
        messageKey,
        nextChainKey: new Uint8Array(ckBits)
    };
}
Enter fullscreen mode Exit fullscreen mode

Conceptually it looks like this:

chainKey₀ ──HMAC(·,0x02)──► chainKey₁ ──HMAC(·,0x02)──► chainKey₂
    │                            │                            │
 HMAC(·,0x01)               HMAC(·,0x01)               HMAC(·,0x01)
    ▼                            ▼                            ▼
messageKey₁                 messageKey₂                 messageKey₃
(AES-GCM msg #1)            (AES-GCM msg #2)            (AES-GCM msg #3)
Enter fullscreen mode Exit fullscreen mode

The messageKey encrypts exactly one message and is then discarded.

If an attacker somehow compromises messageKey₂, they can decrypt only message #2. They cannot derive chainKey₀ from it because HMAC is one-way.

This is the encrypted envelope that goes to the server:

{
  "envelope": {
    "ciphertext": "qzgHSg7zbwU6h8j8RqCPUYBWHJLi78eR9C0tj9I=",
    "nonce": "6KPcVjbpM4FUB0Vz",
    "senderIdentityPublicKey": "B4pERe0xKmSdiQPR+kLWWmI0nloC8Za3RBTg+occHF0=",
    "targetDeviceId": "device-2aa3ae0e-ee08-4261-aa09-7d8f800b61e9",
    "messageType": "PREKEY_WHISPER"
  }
}
Enter fullscreen mode Exit fullscreen mode


Important limitation: this is not full Double Ratchet yet

This part matters.

The current implementation has X3DH and a symmetric ratchet, but it does not yet implement the full Double Ratchet algorithm.

In Signal's Double Ratchet, there is also a DH ratchet step. Both sides periodically perform a new Diffie-Hellman exchange and update the root key. That provides break-in recovery: even if some state is compromised, future DH ratchet steps can restore security once the attacker loses access.

My current version does not have that yet.

So the honest description is:

X3DH session setup + symmetric message ratchet + AES-GCM encryption.

Not full Signal Protocol. Not production-grade secure messaging. Not audited cryptography.

The DH ratchet step is the first major item on the roadmap.


Multi-device delivery: one user, many envelopes

A normal messenger user may have a laptop, a phone, and maybe another browser session.

In a non-E2EE system, the server could store the plaintext once and show it everywhere.

In an E2EE system, the server cannot do that.

If Bob has two devices, Alice must create a separate encrypted envelope for each target device. The server cannot decrypt one envelope and re-encrypt it for another device because it does not have the key.

// crypto-engine.js — fanout to all target devices
async function buildFanoutRequest(api, chatId, plainText) {
    const localBundle = await ensureDeviceRegistered(api);
    const resolved = await api('/api/crypto/resolve-chat-devices/' + chatId, {
        method: 'POST'
    });

    const envelopes = [];

    for (const targetDevice of resolved.targetDevices) {
        if (targetDevice.deviceId === localBundle.deviceId) {
            const encrypted = await encryptSelfEnvelope(localBundle, plainText);
            envelopes.push({
                ...encrypted,
                messageType: 'SELF_WHISPER'
            });
            continue;
        }

        let session = getSession(localBundle.deviceId, targetDevice.deviceId);
        let ephemeralPublicKey = null;

        if (!session) {
            const created = await createInitiatorSessionWrapped(localBundle, targetDevice);
            session = created.session;
            ephemeralPublicKey = created.ephemeralPublicKey;
        }

        const { encrypted, messageIndex } = await encryptWithRatchet(session, plainText);
        storeSession(localBundle.deviceId, targetDevice.deviceId, session);

        envelopes.push({
            targetDeviceId: targetDevice.deviceId,
            ciphertext: encrypted.ciphertext,
            nonce: encrypted.nonce,
            messageIndex,
            ephemeralPublicKey,
            messageType: ephemeralPublicKey ? 'PREKEY_WHISPER' : 'WHISPER'
        });
    }

    return { chatId, envelopes };
}
Enter fullscreen mode Exit fullscreen mode

This is one of the places where E2EE directly affects backend design.

The backend does not store "a message". It stores a logical message plus multiple device-specific envelopes.


Backend: storing and routing ciphertext

On the backend side, the message content is intentionally replaced with a placeholder:

// MessageService.java
message.setContent("[encrypted]");
messageRepository.save(message);

Map<String, MessageEnvelope> byDevice =
        persistEnvelopes(message, sender, request.getEnvelopes());
Enter fullscreen mode Exit fullscreen mode

Then each envelope is delivered to a device-specific STOMP topic:

// per-device delivery over WebSocket
private void fanoutCreatedEvent(
        Message message,
        Map<String, MessageEnvelope> byDevice
) {
    byDevice.forEach((deviceId, envelope) ->
        messagingTemplate.convertAndSend(
            "/topic/devices/" + deviceId + "/chats/" + message.getChatId(),
            toDeviceEvent(
                "MESSAGE_CREATED",
                message,
                envelope,
                envelope.getTargetUserId()
            )
        )
    );
}
Enter fullscreen mode Exit fullscreen mode

The important part is the destination:

/topic/devices/{deviceId}/chats/{chatId}
Enter fullscreen mode Exit fullscreen mode

It is not a global chat broadcast. It is per-device routing.

That distinction matters because the encrypted payload is different for every target device.


The whole architecture

At a high level, the system looks like this:

Browser
├── crypto-engine.js   ← X3DH · Symmetric Ratchet · AES-GCM · WebCrypto
├── REST /api/*        ← auth · chats · devices · prekeys
└── WebSocket /ws      ← per-device STOMP topics

Spring Boot
├── auth/              ← phone OTP · email · JWT
├── crypto/            ← device registry · prekey bundles · envelope fanout
├── message/           ← encrypted envelopes · receipts · events
└── infra/ws/          ← WebSocket · JWT auth · device routing

PostgreSQL  ← users · devices · messages([encrypted]) · envelopes(ciphertext)
Redis       ← refresh tokens · online presence · SMS rate limits
Observability ← Actuator · Prometheus · Grafana
Enter fullscreen mode Exit fullscreen mode


The bug I did not notice immediately

There is a chat list in the UI, and every chat needs a preview of the last message.

In a normal app, this is trivial:

select content
from messages
where chat_id = ?
order by created_at desc
limit 1;
Enter fullscreen mode Exit fullscreen mode

So I implemented the backend query, opened the UI, and saw this in every chat:

[encrypted]
Enter fullscreen mode Exit fullscreen mode

Of course.

The server does not know what the last message says.

I spent some time thinking about how to solve this on the backend. Then the obvious answer finally clicked:

you cannot solve this on the backend because the backend does not have the key.

The preview must be a client-side concern.

previewCache.set(chatId, decryptedText.slice(0, 60));

const preview =
    previewCache.get(chatId) ?? '🔒 Encrypted';
Enter fullscreen mode Exit fullscreen mode

This was a useful mental shift.

In a normal backend system, a message preview is a SQL problem.

In an E2EE system, a message preview is local decrypted client state.


Rate limiting: the boring security feature that matters

The endpoint /api/auth/send-code is dangerous if left unprotected.

Without rate limiting, it becomes an SMS pumping endpoint.

So I added Redis-based limits:

public void checkAndIncrement(String phone) {
    checkLimit(
        "sms:rate:short:" + phone,
        3,
        Duration.ofMinutes(10)
    );

    checkLimit(
        "sms:rate:day:" + phone,
        10,
        Duration.ofHours(24)
    );
}

private void checkLimit(String key, int maxAttempts, Duration window) {
    Long count = redisTemplate.opsForValue().increment(key);

    if (count == 1) {
        redisTemplate.expire(key, window);
    }

    if (count > maxAttempts) {
        long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
        throw new RateLimitException("Too many requests", ttl);
    }
}
Enter fullscreen mode Exit fullscreen mode

When the limit is exceeded, the backend returns HTTP 429 with a Retry-After header.

This is not the most exciting part of the project, but it is exactly the kind of thing that separates a demo from a more realistic system.


WebSocket authentication

The WebSocket connection is authenticated with JWT during the STOMP CONNECT command:

// WebSocketAuthChannelInterceptor.java
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
    StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

    if (StompCommand.CONNECT.equals(accessor.getCommand())) {
        String token = accessor.getFirstNativeHeader("Authorization");

        if (token == null || !token.startsWith("Bearer ")) {
            throw new AuthException("Missing WebSocket auth token");
        }

        Authentication auth =
                jwtAuthProvider.authenticate(token.substring(7));

        accessor.setUser(auth);
    }

    return message;
}
Enter fullscreen mode Exit fullscreen mode

JWT alone is not enough, though.

For this architecture, the server must also validate the device identity. A client should not be able to subscribe as any arbitrary deviceId.

Otherwise per-device encryption would degrade into a routing illusion.

The device must be registered and must belong to the authenticated user.


What I built

Current features:

  • X3DH-based session setup;
  • symmetric ratchet with AES-GCM message encryption;
  • encrypted per-device envelopes;
  • multi-device delivery;
  • direct and group chats;
  • WebSocket/STOMP realtime messaging;
  • typing indicators;
  • online presence;
  • read receipts;
  • message editing;
  • soft deletion;
  • photo attachments;
  • phone OTP and email/password authentication;
  • JWT access/refresh tokens;
  • Redis-based SMS rate limiting;
  • PostgreSQL + Flyway migrations;
  • Prometheus + Grafana observability;
  • backend tests with Testcontainers;
  • frontend tests with Vitest;
  • GitHub Actions CI.

What is intentionally still on the roadmap:

  • full Double Ratchet with DH ratchet step;
  • non-extractable client keys;
  • Android client with Android Keystore;
  • real SMS provider integration;
  • push notifications;
  • encrypted voice messages;
  • encrypted media storage;
  • WebRTC calls.

The main lesson

The main lesson from this project is simple:

E2EE is an architecture decision, not a library.

You cannot take a normal Spring Boot chat and just "turn encryption on".

If the backend is not supposed to be trusted, that decision affects everything:

  • database schema;
  • API shape;
  • WebSocket topics;
  • multi-device delivery;
  • message previews;
  • logs;
  • file attachments;
  • device management;
  • frontend state;
  • threat model.

A messenger is not just "a chat with WebSocket".

In an E2EE model, it is a system for delivering encrypted envelopes to specific devices.

Once that idea becomes clear, many strange-looking implementation decisions start to make sense.


Repository

The project is open source:

github.com/vaazhen/chaos-messenger

The repository includes English and Russian README files, diagrams, screenshots, security audit notes, Docker Compose setup, and one-command local startup.

I would be especially interested in feedback on:

  • prekey rotation;
  • non-extractable WebCrypto keys;
  • browser-based E2EE limitations;
  • DH ratchet implementation;
  • encrypted media design;
  • multi-device state synchronization.

Top comments (0)