Skip to content
OpenE2EE
Engineering journal

TLS is not end-to-end encryption

TLS protects the wire. Your server still reads every message it carries, and encryption at rest does not change that boundary.

Who this is for: a JavaScript or TypeScript developer who ships HTTPS and stores plaintext in a database. A customer, security questionnaire, or lawyer asks whether the product uses end-to-end encryption. For most server-readable systems, the answer is no.

TLS stops a coffee-shop network from reading your users’ messages. It does not stop your own server from reading them. This article shows where the boundary moves, in code, and what it does not cover.

What TLS promises

TLS secures one connection between two endpoints. A load balancer, CDN edge, or application process usually terminates that connection. The terminating endpoint holds the key and decrypts the ciphertext. This behavior lets the server read and serve the request.

Under TLS alone, a server decrypts an incoming message and encrypts it again for the next connection. Between those operations, plaintext can reach memory, request logs, and the Postgres row. Copies can also reach the search index, analytics pipeline, nightly backup, and crash dump.

“Encryption at rest” does not change this boundary. Disk-level or column-level encryption protects a stolen drive. Your application holds the decryption key and uses it on every read. A leaked credential or misconfigured internal tool can therefore expose the contents. A subpoena or an engineer’s misuse can also reach readable data.

Who holds readable plaintext under TLS, and who holds it under end-to-end encryption Two rows with the same devices, the same sealed envelopes in transit and the same middle position. In the top row, labeled TLS only, four dotted boundaries mark the message being sealed, opened at the server, re-sealed and opened again; the server is drawn as an open outlined box holding readable content, because it does. In the bottom row, labeled end-to-end encrypted, there are only two boundaries — one at each device — and the middle is a relay container holding two sealed slabs with metadata ticks above them, carrying envelopes it has no way to open. TLS onlysealopensealopendeviceyour server · reads every messagedeviceEnd-to-end encryptedsealopen deviceyour relay · carries, never needs plaintextdevicemetadata still visible

Under TLS the server opens and reseals the message, so the middle holds readable content. Under end-to-end encryption two device boundaries contain the plaintext.

End-to-end encryption moves the boundary. The sending device generates keys and encrypts the message. The receiving device uses its local keys to decrypt it. Everything between the devices is transport. The middle carries the message without interpreting it.

How E2EE changes your application architecture covers the wider application effects.

What it looks like in code

This complete round trip uses the OpenE2EE Signal Protocol SDK and its in-memory relay. The protocol and the cryptography are real. Only the infrastructure is simulated:

import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { inMemoryStore } from '@open-e2ee/signal-protocol-sdk/local/store/memory';
import { inMemoryRelay } from '@open-e2ee/signal-protocol-sdk/remote/relay/memory';

const relay = inMemoryRelay();
await relay.registerDevice('alice', { encryptedDeviceName: new ArrayBuffer(0) });
await relay.registerDevice('bob', { encryptedDeviceName: new ArrayBuffer(0) });

const alice = await createSignalProtocolClient({
  identity: { userId: 'alice' },
  adapters: { storage: inMemoryStore(), relay },
});
const bob = await createSignalProtocolClient({
  identity: { userId: 'bob' },
  adapters: { storage: inMemoryStore(), relay },
});

await alice.syncToServer();
await bob.syncToServer();

bob.registerHook('onMessageDecrypted', (message) => {
  console.log(message.content); // plaintext, only on Bob's device
});
bob.startRelaySubscription();

await alice.send('bob', 'Dinner at 7. I got us the table by the window.');

The SDK requires adapters.storage. It owns local encryption state for exactly one user on one device. This state includes identity keys, prekeys, session records, and ratchet state. adapters.relay is optional and connects to your relay. The SDK needs a local store but can work without a relay.

Install it from npm:

npm install @open-e2ee/signal-protocol-sdk

What your database holds

The record below shows what the relay held after the code ran. It uses the captured envelope, not an illustrative value. The panel shows the plaintext beside it for comparison.

device — bob

Dinner at 7. I got us the table by the window.

sealplaintext stops here
relay — the row in your database

ciphertext — 3,532 base64 characters, 2,648 bytes decoded, excerpt shown

targetUserId
bob
targetDeviceId
1
senderUserId
alice
senderDeviceId
1
messageType
prekey_bundle
timestamp
1786899316795
recipientRegistrationId
11213
serverTimestamp
1786899316805

and a relay-assigned envelope id, in whatever format your relay assigns

recorded by running the quickstart@open-e2ee/signal-protocol-sdk@0.2.0every field shown is still in the envelope at 1.0.0

The relay has a recipient, device, sender, and message type. It also has two timestamps, a registration id, and 3,532 base64 ciphertext characters. The routing fields identify the delivery target. The prekey_bundle type marks a session-establishing message. Later session messages carry the ciphertext type.

The relay never needs message plaintext or device private keys. It is not blind. It sees the outside of the envelope, and the outside of an envelope is real information. It can observe who communicates, how often, at which hours, and at what size.

The SDK’s sealed-sender module blanks sender fields on the identified-delivery path. It reduces linkability and does not provide anonymity. Limits and metadata (opens in a new tab) documents the published research and the limits.

What breach-notification law considers

E2EE is not a universal compliance requirement. Each law and security program sets its own scope and controls. What it changes is the readable content a relay holds after a breach.

Several breach-notification rules turn on whether an unauthorized person could read the acquired data:

  • California Civil Code §1798.82 (opens in a new tab) applies to qualifying people and businesses. Its notice rule covers acquired unencrypted personal information. It also covers encrypted information when an unauthorized person gets a key that could make it readable.
  • The FTC Safeguards Rule (opens in a new tab) applies to covered financial institutions. A notification event involves at least 500 consumers’ unencrypted information. The rule includes encrypted information when an unauthorized person accesses its encryption key.
  • GDPR Article 34(3)(a) (opens in a new tab) addresses communication to affected people. That communication is not required when applied protections make the affected data unintelligible to unauthorized people. Other GDPR duties can still apply.

Server-side encryption leaves the decryption key in the server environment, so one compromise can expose both the database and the path to read it. In this architecture the relay does not hold device private keys.

This is not legal advice. Applicable law and incident facts determine notification and disclosure duties. Relay metadata can still show that people communicated, and that metadata can itself require notice. Ask counsel.

IBM’s 2025 Cost of a Data Breach report (opens in a new tab) put the US average at $10.22M. It put healthcare at $7.42M. IBM described healthcare as the highest-cost sector for the fourteenth consecutive year. E2EE does not prevent a breach. It changes what a breached relay yields.

What this does not buy you

Endpoints are still endpoints. If an attacker compromises a device, the attacker enters the plaintext boundary. E2EE protects the middle, not a compromised endpoint.

The browser has a code-delivery problem. In a web app, the server also ships the JavaScript that encrypts content. Subresource integrity and reproducible builds help. The code-delivery problem remains open. The browser quickstart (opens in a new tab) states this limit.

First contact uses trust on first use (TOFU). Neither person verifies the other during the first key exchange. The SDK pins the composite identity tuple per user, and fails closed after a later identity change. Pinning detects a change but does not prove the original identity. Safety numbers support that verification ceremony. The threat model (opens in a new tab) explains the boundary.

Recovery becomes a product decision. A relay that cannot read the data cannot restore it. Define the lost-phone policy before launch. Recovery, backup, and migration (opens in a new tab) compares the tradeoffs.

Metadata remains. Routing fields, timestamps, and message size stay visible to the relay.

Where to go from here

Run the code above and read the row your relay stores. It takes about ten minutes, with no account and no server.

Build your first encrypted message → (opens in a new tab)

E2EE vs TLS (opens in a new tab) describes the boundary in more detail. The threat model (opens in a new tab) separates SDK guarantees from application responsibilities. Review the production checklist (opens in a new tab) before release.