Encrypted Direct Messages
NIP-04 enables private messaging on Nostr using kind 4 events with AES-256-CBC encryption. Messages are end-to-end encrypted between sender and recipient.
NIP-04: Encrypted Direct Messages
Status: Final (but see Security Considerations) Author: fiatjaf Category: Social Features
Overview
NIP-04 defines how Nostr users send private, encrypted direct messages (DMs). Messages use kind 4 events with AES-256-CBC encryption based on a shared secret derived via ECDH (Elliptic Curve Diffie-Hellman).
Key Features:
- End-to-end encryption - Only sender and recipient can read messages
- Decentralized - No central server holds your messages
- Portable - DMs sync across all your clients
- Nostr-native - Uses the same event/relay infrastructure
Important: NIP-04 has known security limitations. Read Security Considerations below.
Why Private Messaging on Nostr?
Compared to Traditional Apps
| Feature | NIP-04 DMs | Traditional Apps |
|---|---|---|
| Encryption | End-to-end (E2EE) | Varies (often E2EE) |
| Portability | Access from any client | Locked to one app |
| Server dependency | Any relay, user’s choice | Centralized servers |
| Censorship | Resistant (multiple relays) | Vulnerable (single company) |
| Identity | Nostr keypair | Phone number or account |
Advantages
✅ Censorship-resistant: No single company can block your messages ✅ Portable: Switch clients without losing message history ✅ Pseudonymous: No phone number or email required ✅ Open protocol: Any client can implement
Disadvantages
⚠️ Metadata visible: Who you message and when is public ⚠️ No perfect forward secrecy: Compromised key exposes all messages ⚠️ Replay attacks: Relays can serve old messages as new ⚠️ Limited adoption: Not all clients implement DMs yet
How It Works
Event Structure
Encrypted DMs use kind 4 events:
{
"id": "...",
"pubkey": "sender_public_key",
"created_at": 1673347337,
"kind": 4,
"tags": [
["p", "recipient_public_key"]
],
"content": "base64_encrypted_message?iv=initialization_vector",
"sig": "..."
}
Key Fields:
- kind: Always
4for encrypted DMs - tags: Single
ptag with recipient’s pubkey - content: Base64-encoded encrypted message + IV
Encryption Process
1. Compute Shared Secret
Use ECDH to derive a shared secret from your private key and recipient’s public key:
import { getSharedSecret } from 'nostr-tools';
// Derive shared secret (same for both sender and recipient)
const sharedSecret = getSharedSecret(myPrivateKey, recipientPubkey);
Both parties compute the same shared secret:
- Sender:
secret = ECDH(myPrivateKey, recipientPubkey) - Recipient:
secret = ECDH(myPrivateKey, senderPubkey)
2. Encrypt the Message
Use AES-256-CBC with the shared secret as the key:
import CryptoJS from 'crypto-js';
// Generate random IV (initialization vector)
const iv = CryptoJS.lib.WordArray.random(128 / 8);
// Encrypt message
const encrypted = CryptoJS.AES.encrypt(
plaintext,
CryptoJS.enc.Hex.parse(sharedSecret),
{
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}
);
// Encode as base64
const encryptedBase64 = encrypted.ciphertext.toString(CryptoJS.enc.Base64);
const ivBase64 = iv.toString(CryptoJS.enc.Base64);
// Create content field
const content = `${encryptedBase64}?iv=${ivBase64}`;
3. Create and Publish Event
const event = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", recipientPubkey]],
content: content, // "encrypted_base64?iv=iv_base64"
pubkey: myPubkey
};
// Sign and publish
const signedEvent = signEvent(event, myPrivateKey);
relay.publish(signedEvent);
Decryption Process
1. Fetch DM Events
Query for kind 4 events where you’re the recipient:
// Fetch DMs sent to you
relay.sub([{
kinds: [4],
"#p": [myPubkey]
}]);
// Fetch DMs you sent
relay.sub([{
kinds: [4],
authors: [myPubkey]
}]);
2. Compute Shared Secret
Use the sender’s pubkey (from the event):
const sharedSecret = getSharedSecret(myPrivateKey, event.pubkey);
3. Decrypt the Message
Parse the content field and decrypt:
// Parse content: "encrypted_base64?iv=iv_base64"
const [encryptedBase64, ivBase64] = event.content.split('?iv=');
// Decode from base64
const encrypted = CryptoJS.enc.Base64.parse(encryptedBase64);
const iv = CryptoJS.enc.Base64.parse(ivBase64);
// Decrypt
const decrypted = CryptoJS.AES.decrypt(
{ ciphertext: encrypted },
CryptoJS.enc.Hex.parse(sharedSecret),
{
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}
);
// Convert to plaintext
const plaintext = decrypted.toString(CryptoJS.enc.Utf8);
Complete Example
Sending a DM
import { getPublicKey, getSharedSecret, signEvent } from 'nostr-tools';
import CryptoJS from 'crypto-js';
function encryptDM(privateKey, recipientPubkey, message) {
// 1. Compute shared secret
const sharedSecret = getSharedSecret(privateKey, recipientPubkey);
// 2. Generate random IV
const iv = CryptoJS.lib.WordArray.random(128 / 8);
// 3. Encrypt message
const encrypted = CryptoJS.AES.encrypt(
message,
CryptoJS.enc.Hex.parse(sharedSecret),
{ iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }
);
// 4. Create content field
const encryptedBase64 = encrypted.ciphertext.toString(CryptoJS.enc.Base64);
const ivBase64 = iv.toString(CryptoJS.enc.Base64);
return `${encryptedBase64}?iv=${ivBase64}`;
}
// Usage
const myPrivateKey = "your_private_key_hex";
const myPubkey = getPublicKey(myPrivateKey);
const recipientPubkey = "recipient_public_key_hex";
const encryptedContent = encryptDM(
myPrivateKey,
recipientPubkey,
"Hello! This is a secret message."
);
// Create and publish event
const dmEvent = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", recipientPubkey]],
content: encryptedContent,
pubkey: myPubkey
};
const signedDM = signEvent(dmEvent, myPrivateKey);
relay.publish(signedDM);
Receiving a DM
function decryptDM(privateKey, senderPubkey, encryptedContent) {
// 1. Compute shared secret
const sharedSecret = getSharedSecret(privateKey, senderPubkey);
// 2. Parse content
const [encryptedBase64, ivBase64] = encryptedContent.split('?iv=');
const encrypted = CryptoJS.enc.Base64.parse(encryptedBase64);
const iv = CryptoJS.enc.Base64.parse(ivBase64);
// 3. Decrypt
const decrypted = CryptoJS.AES.decrypt(
{ ciphertext: encrypted },
CryptoJS.enc.Hex.parse(sharedSecret),
{ iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }
);
// 4. Convert to plaintext
return decrypted.toString(CryptoJS.enc.Utf8);
}
// Usage (when receiving an event)
relay.on('event', (event) => {
if (event.kind === 4 && event.tags[0][1] === myPubkey) {
const plaintext = decryptDM(
myPrivateKey,
event.pubkey, // sender
event.content
);
console.log("Received DM:", plaintext);
}
});
Security Considerations
⚠️ NIP-04 has known security limitations. It was designed for simplicity, not maximum security.
Known Vulnerabilities
1. No Perfect Forward Secrecy (PFS)
If your private key is compromised:
- All past DMs can be decrypted (even old messages)
- All future DMs can be decrypted (until you change keys)
Why: Uses static keypairs, not ephemeral keys like Signal’s Double Ratchet.
Mitigation:
- Keep your private key secure
- Consider rotating keys periodically
- Use NIP-17 for better security (see below)
2. Metadata Leakage
The following is publicly visible:
- Who you’re messaging (recipient pubkey in
ptag) - When you sent the message (
created_attimestamp) - Message frequency and timing patterns
Why: Kind 4 events are stored on public relays.
Mitigation:
- Relays can’t read content, but they can see metadata
- Consider using privacy-focused relays
- Be aware of traffic analysis risks
3. Replay Attacks
Malicious relays can:
- Serve old messages as new
- Duplicate messages
- Reorder message delivery
Why: No sequence numbers or timestamp verification.
Mitigation:
- Clients should deduplicate by event ID
- Display timestamps to users
- Treat DMs as eventually-consistent
4. Padding Oracle Attacks
Improper PKCS7 padding validation could leak information.
Mitigation: Use constant-time padding validation (library-dependent).
NIP-17: Improved Private Messages
⚠️ NIP-04 is deprecated in favor of NIP-17 for new implementations.
NIP-17 provides:
- ✅ Sealed sender (hides sender identity from relays)
- ✅ Better metadata protection
- ✅ Improved key derivation
- ✅ Gift wrapping for privacy
However: NIP-04 remains widely supported for backward compatibility.
Recommendation:
- New clients should implement NIP-17
- Continue supporting NIP-04 for legacy messages
- Encourage users to migrate
Client Support
Full NIP-04 Support
- Damus - Encrypted DMs with clean UI
- Primal - Fast DM sync across platforms
- Amethyst - Android-native encrypted messaging
- Snort - Web-based DMs
- Iris - Simple DM interface
Partial Support
- Some clients may not implement DMs at all
- Some use NIP-17 instead
Check our Client Directory for specific features.
Privacy Best Practices
For Users
- Understand the limitations: NIP-04 is not Signal or WhatsApp
- Don’t share highly sensitive info: Use stronger tools for that
- Verify recipient’s key: Ensure you’re messaging the right person
- Use trusted relays: They can’t read messages but see metadata
- Secure your private key: Compromise = all DMs exposed
For Developers
- Implement NIP-17 for new clients (better security)
- Support NIP-04 for backward compatibility
- Warn users about metadata visibility
- Use secure libraries: Don’t roll your own crypto
- Constant-time operations: Prevent timing attacks
Common Questions
Can relays read my DMs?
No. Relays store encrypted content. Without your private key and the other person’s public key, they can’t decrypt messages.
But: Relays can see who you’re messaging and when.
Can I delete DMs?
You can send a deletion request (NIP-09), but:
- Relays may ignore it
- Recipients already have the message
- True deletion is impossible
Are group DMs supported?
Not in NIP-04. It only supports 1-to-1 messaging. For groups:
- Use NIP-28 (public chat)
- Use NIP-17 (private groups - experimental)
What if I lose my private key?
You lose access to:
- All DM history
- Ability to decrypt future messages to that key
- Ability to send DMs from that identity
No recovery is possible. Back up your key securely.
Related NIPs
- NIP-01 - Basic protocol (event structure)
- NIP-17 - Private direct messages (improved security)
- NIP-44 - Encrypted payloads (versioned encryption)
For Developers
Implementation Checklist
Sending DMs:
- Compute shared secret using ECDH
- Generate random IV for each message
- Encrypt with AES-256-CBC
- Format:
base64_encrypted?iv=base64_iv - Create kind 4 event with p tag
- Sign and publish
Receiving DMs:
- Subscribe to kind 4 events (
#p= your pubkey) - Compute shared secret with sender’s pubkey
- Parse content (split on
?iv=) - Decrypt with AES-256-CBC
- Handle decryption errors gracefully
Security:
- Use secure crypto libraries
- Never log private keys or shared secrets
- Warn users about metadata visibility
- Deduplicate messages by event ID
- Consider implementing NIP-17 instead
Recommended Libraries
JavaScript:
import { getSharedSecret } from 'nostr-tools';
import CryptoJS from 'crypto-js';
// Or use built-in nip04 helpers
import { nip04 } from 'nostr-tools';
const encrypted = await nip04.encrypt(privateKey, recipientPubkey, message);
const decrypted = await nip04.decrypt(privateKey, senderPubkey, encrypted);
Python:
from nostr.nip04 import encrypt_message, decrypt_message
encrypted = encrypt_message(private_key, recipient_pubkey, "Hello!")
decrypted = decrypt_message(private_key, sender_pubkey, encrypted)
Technical Specification
For the complete technical specification, see NIP-04 on GitHub.
Summary
NIP-04 enables encrypted direct messaging on Nostr:
✅ Kind 4 events for private messages ✅ AES-256-CBC encryption with ECDH shared secrets ✅ End-to-end encrypted (relays can’t read content) ✅ Portable across all clients
⚠️ Security limitations:
- No perfect forward secrecy
- Metadata is public
- Vulnerable to replay attacks
For better security, consider NIP-17 (improved private messages).
Next Steps:
- Learn about identity verification in NIP-05
- Explore improved messaging in NIP-17
- Understand bech32 encoding in NIP-19
- Browse all NIPs in our reference
Last updated: January 2024 Official specification: GitHub
Client Support
This NIP is supported by the following clients: