How Glyph keeps your chats private.
A plain-English guide to what actually happens to a message — locks, keys, and the one promise underneath it all: every message is sealed on your device and only opened on the other person's. The server in the middle only ever holds scrambled, unreadable data.
Next.js · React · Supabase · @noble · X25519 · Ed25519 · XChaCha20-Poly1305
Every message is locked on your phone or laptop before it leaves, and only unlocked on the other person's device. This is end-to-end encryption — and the rest of this page is really just explaining how that one promise is kept.
What is cryptography?
Cryptography is the science of scrambling information so only the right person can unscramble it — and of proving who really sent something.
Imagine passing a note across a noisy classroom. Many hands touch it on the way. You want two things: nobody reading it en route can understand it, and the person who gets it knows it truly came from you. Those goals — secrecy and authenticity — are the heart of cryptography.
Keys and locks
A key is just a very large secret number. A lock (an algorithm) uses that key to encrypt or decrypt a message. There are two families of locks:
Symmetric: a normal padlock — the same key locks and unlocks. Fast, but both people must already share that key.
Asymmetric: a magic mailbox. Anyone can drop a letter through the public slot (your public key), but only you hold the key that opens the box (your private key). You can hand the public key to the whole world safely.
PUBLIC / PRIVATE KEY PAIR
┌──────────────┐ ┌──────────────┐
│ PUBLIC KEY │ ── give to ──▶ │ Anyone can │
│ (open) │ everyone │ LOCK to you │
└──────────────┘ └──────────────┘
│ (mathematically linked, one pair)
▼
┌──────────────┐
│ PRIVATE KEY │ ── keep secret, never share ──
│ (yours) │ only this UNLOCKS the box
└──────────────┘Three jobs cryptography does for Glyph
| Job | Plain meaning | Real-world parallel |
|---|---|---|
| Encryption | Scramble a message so only the right key can read it. | Putting a letter in a locked box. |
| Signature | Attach unforgeable proof that you wrote this exact message. | A wax seal stamped with your ring. |
| Hash | Turn any data into a short fingerprint that changes if one letter does. | A tamper-evident summary you compare. |
The tools Glyph uses
Glyph never rolls its own crypto. It combines small, well-reviewed building blocks — the @noble family — into a standard, trusted design.
| Real name | What it does | Think of it as… |
|---|---|---|
X25519 | Two people agree on a shared secret over an open channel. | Mixing paint to reach the same secret shade. |
Ed25519 | Creates and checks signatures — proof of who wrote something. | A personal wax seal. |
XChaCha20-Poly1305 | The lockbox: encrypts and detects tampering. | A tamper-proof safe. |
HKDF / HMAC | Turns one secret into many keys, safely. | One master key cut into many. |
BIP-39 | Turns your secret into 12 English words (and back). | A human-friendly backup. |
X3DH | The first-hello handshake — works even if you're offline. | A sealed welcome packet left at the post office. |
Double Ratchet | Gives every message its own fresh key. | A new lock on every letter. |
It's all wired together under lib/crypto/ — identity.ts, seal.ts, ratchet.ts, x3dh.ts, file.ts. The rest of this guide walks through them by following a message.
Your identity & account
Glyph has no email and no password. Your identity is a pair of cryptographic keys, born from 12 random words.
It all starts from a seed
When you sign up, your device generates a random 12-word seed phrase (the BIP-39 standard). Those words are your account. From that single seed, Glyph deterministically derives both key pairs — so the same words always rebuild the same keys on any device.
// A device identity: an Ed25519 signing key and an X25519 key-exchange key.export type Identity = { signing: KeyPair // Ed25519 — proves authorship dh: KeyPair // X25519 — agrees on message keys} // Deterministically derive both keypairs from a BIP-39 seed.export function deriveIdentity(seed: Uint8Array): Identity { const edSk = hkdf(sha256, seed, SALT, encodeUtf8("ed25519"), 32) const dhSk = hkdf(sha256, seed, SALT, encodeUtf8("x25519"), 32) return { signing: { privateKey: edSk, publicKey: ed25519.getPublicKey(edSk) }, dh: { privateKey: dhSk, publicKey: x25519.getPublicKey(dhSk) }, }}hkdf(...)is the "make many keys from one" tool, turning your seed into two distinct private keys. Public keys are safe to share; private keys never leave your device.
Logging in without a password
So how does the server know it's you, with no password? Your device signs a small challenge (your public key plus the time) with your private Ed25519 key. The server checks the signature with your public key, then hands back a short-lived ticket (a JWT) valid for one hour.
// Key-based auth. No email/phone: a client proves ownership of its// Ed25519 identity by signing a fresh challenge...ok = await verifyEd25519(sign_pub, signature, `${sign_pub}.${ts}`)if (!ok) return json({ error: "Bad signature" }, 401)// ...verified, so mint a short-lived (1 hour) ticket whose sub = users.idconst token = await mintJwt(user.id, sign_pub)What "anonymous identity" really means
| Name | Who sees it | Reveals who you are? |
|---|---|---|
| Your public keys | The server & people you talk to | No — random numbers from a random seed, not your name, phone, or email. |
| Username / handle | Public directory (if you choose one) | Optional. Stay null and be unlisted entirely. |
| Petname (e.g. “Mom”) | Only you, on your device | Never sent to the server — stored encrypted. |
The server knows "key #A1B2 talked to key #C3D4," but was never given an email, phone number, or legal name. Even your contact list is stored as ciphertext the server can't read.
Sending a text message
Let's follow the word “hello” from Alice's keyboard all the way to Bob's screen.
Alice writes "hello," locks it in a box only Bob's key can open, stamps it with her personal wax seal, and mails it. The post office (the server) carries the box and sees the address, but can't open it or fake the seal.
The two locked copies
Alice also needs to read her own sent message later (e.g. on another device). So Glyph makes two locked copies — one Bob can open, and one Alicecan open (a "self-copy"). Only ciphertext is stored.
const plaintext = encodeUtf8(params.text) // Encrypt-to-self so the sender can read their own message back.const senderCopy = sealMessage(plaintext, params.identity.dh.publicKey, params.identity) // The copy for the peer uses the Double Ratchet (a fresh key per message).const state = await initiatorRatchet(params.peer, params.identity)const msg = ratchetEncrypt(state, plaintext)envelope = { v: 2, x3dh: state.pendingX3dh, msg } await getSupabase().from("messages").insert({ sender_id: params.meId, recipient_id: params.peer.id, envelope, // ciphertext for the peer sender_copy: senderCopy, // ciphertext for me})Inside the lockbox: sealMessage
A brand-new throwaway ephemeral key is made for this message, both sides derive the same secret with X25519, the text is encrypted with XChaCha20-Poly1305, and the whole thing is signedwith Alice's Ed25519 key so Bob can prove it's from her.
const ephSk = x25519.utils.randomSecretKey() // fresh per messageconst ephPub = x25519.getPublicKey(ephSk)const shared = x25519.getSharedSecret(ephSk, recipientDhPublicKey)const key = deriveKey(shared, ephPub, recipientDhPublicKey) const nonce = randomBytes(24)const ct = xchacha20poly1305(key, nonce, sender.signing.publicKey).encrypt(plaintext)const sig = ed25519.sign(concatBytes(ephPub, nonce, ct), sender.signing.privateKey)On the other end, Bob's device does the reverse — but checks the signature firstand refuses anything that doesn't verify:
if (!ed25519.verify(sig, concatBytes(ephPub, nonce, ct), from)) { throw new Error("Bad signature: message is not from the claimed sender")}const shared = x25519.getSharedSecret(recipient.dh.privateKey, ephPub)const key = deriveKey(shared, ephPub, recipient.dh.publicKey)return xchacha20poly1305(key, nonce, from).decrypt(ct)How it travels
The locked message sits in a database table. Bob's device keeps an open live connection (Supabase Realtime) that instantly notifies him when a new row is addressed to him — no refreshing needed.
Forward secrecy & the ratchet
If someone steals one of your keys far in the future, they still can't read your old conversations. That property is called forward secrecy.
Imagine the lock clicks forward to a brand-new combination after every single message, and can't be spun backward. Steal today's combination and you learn nothing about yesterday's messages — those locks are gone forever. That one-way clicking is the "ratchet."
Two ratchets working together
- The symmetric ratchet derives a fresh single-use key for each message, clicking forward each time.
- The Diffie-Hellman ratchet mixes in a brand-new X25519 key whenever the conversation changes direction. This gives post-compromise recovery: even after a brief key theft, the conversation "heals" once you exchange a couple of messages.
THE CHAIN OF ONE-TIME KEYS (cannot run backwards)
root ─▶ Key#1 ─▶ Key#2 ─▶ Key#3 ─▶ Key#4 ─▶ ...
│ │ │ │
▼ ▼ ▼ ▼
"hi" "how" "are" "you"
🔒 🔒 🔒 🔒
▲
steal Key#3 ──┘ → CANNOT derive Key#1 or Key#2
→ past messages stay lockedThe first hello: X3DH
To start a ratchet, both devices need a shared starting secret — even if one person is offline. X3DH solves this: at sign-up your device uploads a bundle of public prekeys. Anyone who wants to message you grabs that bundle and sets up a session immediately, without you being online.
The first message becomes an envelope of v: 2 with an attached x3dh handshake header. Once the peer replies, the handshake drops and every following message is a pure ratchet step. The legacy v: 1 sealed envelope from Section 4 is still used for your self-copy and as a fallback.
Sending media
Photos and files are big, so they aren't crammed inside a text message. The file is locked separately, and only its key is tucked inside the encrypted message.
You put the photo in a locked storage locker (the file store), then mail your friend a sealed letter containing only the locker's combination. The storage company holds the locker but never the combination — so they can't peek inside.
The file is encrypted in your browser's memory with a fresh random key, and only the ciphertext is uploaded. The key and nonce are returned to the caller — never saved to the server in plaintext.
export function sealBytes(data: Uint8Array): EncryptedBlob { const key = randomBytes(32) const nonce = randomBytes(24) const ciphertext = xchacha20poly1305(key, nonce).encrypt(data) return { ciphertext, key: toBase64(key), nonce: toBase64(nonce) }}The upload stores only the ciphertext, then returns a descriptor — the storage path plus the key and nonce. That descriptor is what gets sealed inside the normal E2E message.
const sealed = sealBytes(bytes) // encrypt the whole fileconst path = `media/${meId}/${crypto.randomUUID()}`await getStorage().upload(path, sealed.ciphertext) // only ciphertext leaves return { kind: "media", path, key: sealed.key, nonce: sealed.nonce, // the secret to open it — goes in the sealed message name: file.name, mime: file.type, size: file.size,}Group chats
The clever part: Glyph adds no new cryptography for groups. A group message is simply the 1-to-1 system from Section 4, repeated once per member.
To tell five friends a secret, you don't shout it. You write five sealed letters — each locked individually to one friend — and tag them with the same reference number. This is called fan-out.
Each copy is a normal sealed message tagged with a shared group_id and a shared client_id. The client_idis the "same announcement" reference number, used later to collapse the many copies into one bubble.
const clientId = crypto.randomUUID() // shared across all copiesconst others = params.members.filter((m) => m.id !== params.meId) for (const member of targets) { await sendText({ // ← the very same 1:1 send from Section 4 meId: params.meId, identity: params.identity, peer: { id: member.id, dhPub: member.dhPub }, text: params.body, groupId: params.groupId, clientId, // so copies dedupe to one bubble })}What the server learns about a group
The server CAN see
- Who is a member of the group (the roster).
- That a message was sent, and when.
The server CANNOT see
- The group's name, photo, or description.
- The message contents (all ciphertext).
Fan-out is beautifully simple and reuses all the 1:1 security — but a message to a 100-person group creates 100 sealed copies. That's fine for normal group sizes and keeps the security model dead simple.
Verifying contacts
Encryption protects the message — but how do you know the public key really belongs to your friend, not an impostor in the middle? You compare a safety number.
Two agents confirm each other by reading out a code phrase. If the phrases match, nobody swapped one of them for a double agent. In Glyph that code phrase is a short fingerprint of your identity key.
// Short human-comparable fingerprint of a signing key — the "safety number".export function fingerprint(signingPublicKey: Uint8Array): string { const digest = sha256(signingPublicKey) return bytesToHex(digest.slice(0, 6)).match(/.{4}/g)!.join(" · ") // e.g. "a1b2 · c3d4 · e5f6"}If Alice and Bob see the samesafety number (in person or over a trusted channel), no man-in-the-middle swapped the keys. Glyph also warns on a sneakier sign: if a contact's signing key stays the same but their encryption key suddenly changes.
The safety number is the one thing a remote attacker can't fake. It's your defense against the man-in-the-middle attack in Section 12.
Disappearing messages
Some messages are meant to be temporary. Glyph supports both timed disappearing messages and view-once media.
Disappearing messages
Set a timer and the message row carries an expires_at timestamp. After that moment it's removed and stops showing up. Same locked message as always — it just has an expiry attached.
View-once media
A photo can be marked view_once. The recipient opens it once; after that, the encrypted blob is deleted and a placeholder marker (media_removed_at) is left behind. Because the file used a one-off key that lived only inside the message, once the blob is gone it's gone — there's no copy the server can resurrect.
These features raise the effort to keep a copy — but no app can stop a determined recipient from screenshotting or photographing their screen. They're about tidiness and reducing accidental permanence, not bulletproof secrecy against the person you chose to message.
Multi-device & the seed
Because your identity is derived from 12 words rather than stored on one phone, you can recreate it anywhere.
Recall that deriveIdentity(seed) is deterministic: the same seed always yields the same keys. So to add a device — or recover after losing one — you simply enter your 12 words and the device rebuilds the exact same identity.
12 WORDS → SAME IDENTITY EVERYWHERE
"ocean velvet ... maple"
│
├──▶ Laptop ──▶ deriveIdentity() ──▶ same keys
├──▶ Phone ──▶ deriveIdentity() ──▶ same keys
└──▶ New PC ──▶ deriveIdentity() ──▶ same keysThose 12 words ARE your account and cannot be reset by anyone — there's no "forgot password," because there is no password. Anyone who has them becomes you; anyone who loses them loses the account. Write them down and store them safely offline.
On your device, the seed is kept encrypted behind your PIN, so a thief who grabs your unlocked-but-not-signed-in device still can't read it.
Where files live
Encrypted media has to be stored somewhere. Glyph keeps it in an object store, with a per-user limit enforced by the database itself.
- What's stored: only ciphertext blobs, at a path like
media/<your-id>/<random>. The decryption key is never there. - Per-file cap:25 MB, checked in the browser before uploading.
- Per-user quota:a default of 1 GiB, enforced by a database trigger that runs before each upload is accepted.
The store uses row-level security: you can only write into your own folder. Others can download a blob (they need to, to receive media you sent) — but it's encrypted, so downloading it without the key from the sealed message is useless.
Threat model
An honest list of who might attack you and what each can or cannot achieve. Good security means being clear-eyed about both.
Can they read your messages?
| Who | Message contents? | Who-talks-to-whom? |
|---|---|---|
| The Glyph server / its operator | No — only ciphertext | Yes — sees routing & rosters |
| A hacker who steals the database | No — still just ciphertext | Yes — same metadata |
| Your ISP / Wi-Fi snoop | No — encrypted in transit | Partially — sees you reach the server |
| The person you messaged | Yes — that's the point | Yes |
What Glyph honestly does NOT protect against
- Metadata: the server still learns who messages whom, when, and group membership. Contents stay secret; the social graph is partly visible.
- A compromised device:malware or a thief on your unlocked phone sees what you see. Cryptography can't help after the endpoint is owned.
- The recipient leaking: anyone you message can screenshot or repeat it. E2E protects against outsiders, not the people you talk to.
- Losing your 12 words:there's no recovery. That's the cost of having no central authority that could also be forced to unlock your account.
Glyph gives you Signal-grade content privacy: strong encryption, forward secrecy, real signatures, and pseudonymous identity — with the server kept blind to everything but the bare routing it needs to deliver mail.
Glossary
| Term | Plain meaning |
|---|---|
| Plaintext | The readable message before it's locked. |
| Ciphertext | The scrambled, unreadable locked message. |
| Public / Private key | A linked pair: share the public half, guard the private half. |
| Symmetric / Asymmetric | Same key both ways (fast) / public locks, private unlocks. |
| Signature | Unforgeable proof of who wrote a message (Ed25519). |
| Hash | A short fingerprint of data; changes if the data does (SHA-256). |
| Nonce | A one-time random value so the same text never encrypts identically twice. |
| Ephemeral key | A throwaway key used for just one message. |
| X25519 / Ed25519 | Agree on a shared secret / create & verify signatures. |
| XChaCha20-Poly1305 | Encryption that hides data and detects tampering. |
| HKDF | Tool that safely turns one secret into many keys. |
| BIP-39 seed | Your master secret expressed as 12 English words. |
| X3DH | First-contact handshake that works even if the other person is offline. |
| Double Ratchet | Gives every message a fresh key (forward secrecy). |
| E2E | Only the two endpoints can read; the server cannot. |
| Fan-out | Sending a group message as one sealed copy per member. |
| Safety number | A short code two people compare to confirm no impostor. |
| Metadata | Data about messages (who, when) rather than their contents. |
| JWT | A short-lived signed ticket proving you're logged in. |
| RLS | Database rules that stop you reading rows that aren't yours. |
You built something genuinely strong. Now you know exactly how.