glyphCreate identity
the cryptography behind glyph

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

the one big idea

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.

01

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:

two kinds 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
  └──────────────┘
A public/private key pair: the public half locks, only the private half unlocks.

Three jobs cryptography does for Glyph

JobPlain meaningReal-world parallel
EncryptionScramble a message so only the right key can read it.Putting a letter in a locked box.
SignatureAttach unforgeable proof that you wrote this exact message.A wax seal stamped with your ring.
HashTurn any data into a short fingerprint that changes if one letter does.A tamper-evident summary you compare.
02

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 nameWhat it doesThink of it as…
X25519Two people agree on a shared secret over an open channel.Mixing paint to reach the same secret shade.
Ed25519Creates and checks signatures — proof of who wrote something.A personal wax seal.
XChaCha20-Poly1305The lockbox: encrypts and detects tampering.A tamper-proof safe.
HKDF / HMACTurns one secret into many keys, safely.One master key cut into many.
BIP-39Turns your secret into 12 English words (and back).A human-friendly backup.
X3DHThe first-hello handshake — works even if you're offline.A sealed welcome packet left at the post office.
Double RatchetGives every message its own fresh key.A new lock on every letter.
where this lives

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.

03

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.

lib/crypto/identity.ts
// 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.

supabase/functions/auth/index.ts
// 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)
Sign up
12 random words
Derive Ed25519 + X25519
Sign challenge
Server stores public keys
Logged in · 1-hour ticket
Account creation: the seed is made and stays on your device; only public keys reach the server.

What "anonymous identity" really means

NameWho sees itReveals who you are?
Your public keysThe server & people you talk toNo — random numbers from a random seed, not your name, phone, or email.
Username / handlePublic directory (if you choose one)Optional. Stay null and be unlisted entirely.
Petname (e.g. “Mom”)Only you, on your deviceNever sent to the server — stored encrypted.
pseudonymous by default

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.

04

Sending a text message

Let's follow the word “hello” from Alice's keyboard all the way to Bob's screen.

analogy

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.

lib/transport/index.ts — sendText()
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.

lib/crypto/seal.ts
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:

lib/crypto/seal.ts — openMessage()
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.

Type “hello”
Lock with Bob's key
Sign with Alice's key
Store envelope + self-copy
Realtime push
Verify ✓ · unlock · show “hello”
Steps in the middle happen entirely on the devices; the server only stores and forwards a sealed box.
05

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.

the spinning lock

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 locked
Each message gets its own key. The chain only moves forward, so old keys can't be recreated.

The 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.

in glyph's code

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.

06

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.

analogy

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.

lib/crypto/file.ts
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.

lib/transport/media.ts
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,}
Pick a photo
Encrypt in browser
Upload ciphertext
Seal descriptor in E2E message
Recipient unlocks → path + key
Download + decrypt locally
The file store only ever holds an unreadable blob; the key rides inside the sealed message.
07

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.

analogy

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.

lib/transport/groups.ts — sendGroupMessage()
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).
trade-off worth knowing

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.

08

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.

analogy

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.

lib/crypto/identity.ts
// 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.

why this matters

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.

09

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.

honest limitation

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.

10

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 keys
The seed phrase is the master backup. Same words in → same cryptographic identity out.
the most important rule in the app

Those 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.

11

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.
access control

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.

12

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?

WhoMessage contents?Who-talks-to-whom?
The Glyph server / its operatorNo — only ciphertextYes — sees routing & rosters
A hacker who steals the databaseNo — still just ciphertextYes — same metadata
Your ISP / Wi-Fi snoopNo — encrypted in transitPartially — sees you reach the server
The person you messagedYes — that's the pointYes

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.
bottom line

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.

13

Glossary

TermPlain meaning
PlaintextThe readable message before it's locked.
CiphertextThe scrambled, unreadable locked message.
Public / Private keyA linked pair: share the public half, guard the private half.
Symmetric / AsymmetricSame key both ways (fast) / public locks, private unlocks.
SignatureUnforgeable proof of who wrote a message (Ed25519).
HashA short fingerprint of data; changes if the data does (SHA-256).
NonceA one-time random value so the same text never encrypts identically twice.
Ephemeral keyA throwaway key used for just one message.
X25519 / Ed25519Agree on a shared secret / create & verify signatures.
XChaCha20-Poly1305Encryption that hides data and detects tampering.
HKDFTool that safely turns one secret into many keys.
BIP-39 seedYour master secret expressed as 12 English words.
X3DHFirst-contact handshake that works even if the other person is offline.
Double RatchetGives every message a fresh key (forward secrecy).
E2EOnly the two endpoints can read; the server cannot.
Fan-outSending a group message as one sealed copy per member.
Safety numberA short code two people compare to confirm no impostor.
MetadataData about messages (who, when) rather than their contents.
JWTA short-lived signed ticket proving you're logged in.
RLSDatabase rules that stop you reading rows that aren't yours.

You built something genuinely strong. Now you know exactly how.