Technical specification

How DiscordPGP works

A full description of the protocol and the cryptography behind it: what runs in your browser, what the server sees, what it can never see, and exactly which algorithms protect your messages. DiscordPGP uses standard OpenPGP (RFC 9580) via OpenPGP.js, with no home-grown crypto. Your keys and messages are ordinary OpenPGP objects that any OpenPGP tool can read.

TL;DR

Every message is a real OpenPGP message. A fresh random AES-256 session key encrypts the text; that session key is then encrypted to each recipient's RSA-2048 public key, and the whole thing is signed with your key (sign-then-encrypt). The armored message is stored, and Discord only ever carries a short link to it. Your private key is generated in your browser and never leaves your device: the server stores public keys only, so it can never read a message.

StandardOpenPGP (RFC 9580)
ConfidentialityAES-256 session key
Key transportRSA-2048 per recipient
AuthenticityOpenPGP signature

Cryptographic primitives

PurposeAlgorithmParameters
Message encryptionAES-256Random session key, integrity-protected (OpenPGP SEIPD)
Key transportRSA-2048Session key wrapped per recipient (RSAES-PKCS1-v1_5)
SignaturesRSA-2048RSASSA-PKCS1-v1_5, SHA-256, over the plaintext
FingerprintsSHA-1OpenPGP v4 fingerprint of the public key (160-bit)
ImplementationOpenPGP.jsRFC 9580, in-browser, Web Crypto under the hood

This is the classic OpenPGP hybrid: a symmetric session key encrypts the payload (fast, any size), and RSA only wraps that small session key for each recipient. The same design that has protected email for decades, now applied to Discord.

Keys

When you create a key, an RSA-2048 OpenPGP keypair is generated entirely in your browser. The armored private key is offered as a download (discordpgp.key) and is never transmitted anywhere. Only the armored public key is registered with the server, bound to your Discord user ID.

openpgp.generateKey({
  type: "rsa", rsaBits: 2048,
  userIDs: [{ name: "DiscordPGP" }],
  format: "armored",
})
// → { privateKey, publicKey }   ASCII-armored OpenPGP keys
// privateKey → downloaded to you, stays on your device
// publicKey  → registered on the server

Because these are ordinary OpenPGP keys, they interoperate with any OpenPGP implementation (GnuPG and others). There is no password and no recovery: whoever holds the private key can read messages addressed to it, and losing it means those messages are unrecoverable. That is the cost of the server never being able to read them.

What the server stores

The backend is a single Cloudflare Worker with a D1 (SQLite) database. It holds:

Public keys

Discord user ID → armored OpenPGP public key. Never private keys.

OpenPGP messages

The armored encrypted-and-signed message, plus sender and recipient IDs for routing. No plaintext, ever.

Presence

Short-lived records of which public keys are active in a channel, for recipient discovery.

Profiles

Your optional public page.

Because the server holds only public material and ciphertext, a full database breach reveals who messaged whom and when, but not a single word of any message.

Encrypting a message

Encryption is a single OpenPGP operation. The library generates a random AES-256 session key, encrypts the plaintext into an integrity-protected packet, wraps the session key to every recipient's public key, and signs it all with your private key.

armored = await openpgp.encrypt({
  message:         createMessage({ text: plaintext }),
  encryptionKeys:  [ ...recipientPublicKeys, myPublicKey ], // you too
  signingKeys:     myPrivateKey,                            // sign-then-encrypt
  format:          "armored",
})

You're always in the recipient list, so you can re-read your own messages. A new session key is generated for every message, so compromising one message never helps decrypt another.

The envelope

The stored object, the only thing the server and Discord ever see, is the armored OpenPGP message plus routing metadata:

{
  "v": 3,                       // format version (3 = OpenPGP)
  "from": "745277370465910875", // sender's Discord ID (routing metadata)
  "recipients": [               // recipient Discord IDs (routing metadata)
    "745277370465910875",
    "883835944166359081"
  ],
  "pgp": "-----BEGIN PGP MESSAGE-----\n…\n-----END PGP MESSAGE-----"
}

The pgp field is a complete OpenPGP message: encrypted session keys (one per recipient) followed by the encrypted, signed payload. Only a listed recipient's private key can unwrap a session key and decrypt it. The from and recipients IDs are only used to look up profiles for the web preview.

Signatures

Confidentiality answers "can an outsider read this?"; signatures answer "did this really come from the sender, unaltered?" DiscordPGP uses standard OpenPGP sign-then-encrypt: the message is signed with your key, then the signature and plaintext are encrypted together. So the signature lives inside the ciphertext and can only be verified by someone who can decrypt, i.e. a genuine recipient. This is exactly how OpenPGP authenticity is meant to work.

Tampering. Any change to the message fails OpenPGP's integrity check and the signature, so a modified message is rejected.

Spoofing. A signed message can't be forged as someone else without their private key.

No custom crypto. It's plain OpenPGP: auditable, and verifiable with any OpenPGP tool. There is no bespoke signature scheme to trust.

When a recipient decrypts (in the extension), OpenPGP verifies the signature against the sender's public key and shows a ✓ / ⚠ badge under the message. The public message page can display that a message is signed and encrypted, but it can't verify the signature: it can't decrypt, and that's the point.

Wire format & delivery

Discord never carries the OpenPGP message directly. It is stored server-side and reduced to a short link inside a familiar armored block:

-----BEGIN DISCORD PGP MESSAGE-----
aHR0cHM6Ly9kaXNjb3JkcGdwLmNvbS9tc2cvTEF1Q3pTdmNHaQ==
-----END DISCORD PGP MESSAGE-----

The body is (by default) the base64 of a https://discordpgp.com/msg/<id> URL. Opening it in a browser shows a viewer with the OpenPGP message and recipients but never any plaintext; the extension instead fetches it and decrypts locally. For safety the client only ever resolves links back to discordpgp.com, so a tampered block can't redirect you elsewhere.

Recipient discovery

To encrypt to someone you need their public key. Rather than scraping user IDs, DiscordPGP uses a channel rendezvous: while you have a channel open, your extension publishes a short-lived presence record (your public key, tied to the current channel ID). When you send, it fetches everyone currently present in that channel and encrypts to each of their keys, plus your own.

Both parties need DiscordPGP active in the same channel for auto-discovery. The server learns which keys are active in which channel, which is metadata (see the threat model).

Decryption & verification

  1. The extension detects the armored block and resolves the link to a message ID.
  2. It fetches the OpenPGP message and hands it to OpenPGP.js with your private key. If one of the encrypted session keys is addressed to your key, the session key is unwrapped and the payload is decrypted.
  3. The library checks OpenPGP's integrity protection, so a modified message won't decrypt.
  4. Using the sender's public key, it verifies the embedded signature and renders the plaintext in place with a signature badge.
{ data, signatures } = await openpgp.decrypt({
  message:          readMessage(armored),
  decryptionKeys:   myPrivateKey,
  verificationKeys: senderPublicKey,
})
await signatures[0].verified   // throws if the signature is invalid

If a message wasn't encrypted for you, none of the session keys are addressed to your key and decryption is impossible.

Fingerprints

Every OpenPGP public key has a fingerprint: the OpenPGP v4 fingerprint (a SHA-1 hash of the public-key packet), shown as grouped hex. Comparing fingerprints out-of-band (in person, over a call) confirms you're encrypting to the right person's real key and not one substituted by the server.

key.getFingerprint()
// → "7A4F 09C2 E81B 5D36 90AE 1F7D 42C8 B65E …"

Threat model

✅ Protected against

  • Discord reading your messages
  • The DiscordPGP server reading your messages
  • Anyone who obtains a message link
  • Tampering with a message (OpenPGP integrity + signature)
  • Impersonating a sender (OpenPGP signatures)
  • A full server/database breach exposing message content

⚠️ Not protected against

  • Metadata: who talks to whom, and when
  • A malicious endpoint on your own device (keylogger, compromised OS)
  • You sharing or losing your private key
  • A recipient choosing to reveal a decrypted message

Honest limitations

Key distribution is trust-on-first-use.

The server maps Discord IDs to public keys; a malicious server could hand out a wrong key. Fingerprint verification is the defense.

No forward secrecy.

Keys are long-term. If a private key is later compromised, past messages addressed to it can be decrypted.

Metadata is visible.

Sender, recipients, timing and channel presence are stored server-side. Content is not.

RSA-2048

A deliberate, conservative choice for browser performance and broad OpenPGP interoperability; it is not post-quantum.

We'd rather state these plainly than overpromise. For most people, moving Discord DMs from "readable by the platform" to "readable only by the people I chose" is a large, real improvement, and that's exactly what this gives you. 🔒