1 Overview
NIP-04 defines kind 4, the first encrypted direct message format in Nostr. A sender encrypts the content field so that only the receiver can read it.
The encryption uses a shared secret. The sender computes this secret from their own private key and the receiver's public key. The receiver computes the same secret from their own private key and the sender's public key. Both sides land on the same secret without ever sending it over the wire. This technique is called ECDH, Elliptic Curve Diffie-Hellman.
Relays still see the sender, the receiver, and the timing of every kind-4 event, even though they cannot read the message text. That gap between "encrypted content" and "private conversation" is the reason this NIP is deprecated.
NIP-04 is unrecommended. Use NIP-17, Private Direct Messages, for all new work. NIP-17 hides the sender, receiver, and event kind from relays, which NIP-04 cannot do. Read this chapter to understand old events and to migrate away from kind 4, not to build new features.
2 The Kind-4 Event
A kind-4 event carries one encrypted message. It must follow three rules, on top of the base event format from NIP-01.
| Field | Rule |
|---|---|
| kind | Must be 4. |
| content | Must be the base64-encoded, aes-256-cbc encrypted message, with the base64 initialization vector appended as a querystring-style parameter named iv. See below. |
| tags | Must contain a p tag naming the receiver. May contain an e tag naming a prior message in the conversation. See §3. |
Deriving the shared secret
The sender combines their private key with the receiver's public key on the curve secp256k1. This produces a shared point. Nostr's shared secret is the X coordinate of that point only, kept as raw bytes.
The default libsecp256k1 ECDH function hashes the shared point with sha256 and uses both X and Y coordinates. Nostr does not do this. Pass a custom hashfp function to secp256k1_ecdh that copies out the raw X coordinate and skips the hash. Using the library default produces a secret that does not match other Nostr clients.
Building the content field
Follow these steps to encrypt a message:
- Compute the shared secret as described above.
- Generate a random 16-byte initialization vector, the IV.
- Encrypt the plaintext with
aes-256-cbc, using the shared secret as the key and the IV. - Base64-encode the ciphertext.
- Base64-encode the IV.
- Join them as
<ciphertext_base64>?iv=<iv_base64>and set this string ascontent.
"content": "<encrypted_text>?iv=<initialization_vector>"
3 Tags
| Tag | Required | Form | Purpose |
|---|---|---|---|
| p | MUST | ["p", "<receiver pubkey, hex>"] | Names the receiver. Lets a relay forward the event to them. |
| e | MAY | ["e", "<event id>"] | Names the previous message in the conversation, or the message this one replies to. |
The p tag is what lets a relay index and forward the message, but it also tells everyone who can read the relay's data exactly who is talking to whom. Encrypting content does not hide this. See §4.
4 Security Warning
This format is far behind the current state of the art for encrypted peer-to-peer communication. It leaks metadata through the event fields.
- Do not use kind 4 for anything that truly needs to stay secret.
- If you must use it, only publish to relays that require NIP-42
AUTHand restrict who may fetch your kind-4 events. - Sender, receiver, and timing are all visible to any relay and to anyone who can read the relay's stored events. Only the message text is hidden.
5 Client Implementation Warning
Clients should not search the decrypted content for public key or note references and replace them, the way NIP-27 mention handling works for regular text notes.
That process turns a reference such as @npub1… into #[0] and adds a matching ["p", "…"] tag. Doing this to a direct message adds a plaintext p tag naming the mentioned user. The tag leaks the mention, and the mentioned user's client will show the encrypted message in their own inbox, even though they cannot read it.
6 Worked Example
Alice sends Bob a kind-4 message, then Bob replies. Orange lines go to the relay. Blue lines come from the relay.
-- Alice computes the shared secret from her privkey and Bob's pubkey -- -- sharedX = ECDH(alice_priv, bob_pub).x (unhashed) -- iv = random 16 bytes -- ciphertext = aes-256-cbc-encrypt("gm bob", key=sharedX, iv=iv) → ["EVENT", {"id":"7ac2a…","pubkey":"a1b2c3…","created_at":1749762600, "kind":4,"tags":[["p","bobpub00…"]], "content":"u3f9skA2==?iv=q8x2Lp0zK1==","sig":"9f21ab…"}] ← ["OK", "7ac2a…", true, ""] -- Bob's client fetches messages addressed to him -- → ["REQ", "dm1", {"kinds":[4], "#p":["bobpub00…"], "limit":20}] ← ["EVENT", "dm1", {"id":"7ac2a…","kind":4,"tags":[["p","bobpub00…"]], "content":"u3f9skA2==?iv=q8x2Lp0zK1==", …}] ← ["EOSE", "dm1"] -- Bob decrypts with ECDH(bob_priv, alice_pub).x and the iv from content -- -- plaintext: "gm bob" -- Bob replies, tagging Alice and referencing the prior event -- → ["EVENT", {"id":"c04e1…","pubkey":"bobpub00…","created_at":1749762660, "kind":4,"tags":[["p","a1b2c3…"],["e","7ac2a…"]], "content":"gm2==?iv=z9y1Nn3xR7==","sig":"3d8e2f…"}] ← ["OK", "c04e1…", true, ""]
7 Code Sample
The steps in §2 translate directly to code. This JavaScript sample uses @noble/secp256k1 for the ECDH step and Node's built-in crypto for aes-256-cbc.
import crypto from 'crypto' import * as secp from '@noble/secp256k1' let sharedPoint = secp.getSharedSecret(ourPrivateKey, '02' + theirPublicKey) let sharedX = sharedPoint.slice(1, 33) let iv = crypto.randomFillSync(new Uint8Array(16)) let cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(sharedX), iv) let encryptedMessage = cipher.update(text, 'utf8', 'base64') encryptedMessage += cipher.final('base64') let ivBase64 = Buffer.from(iv.buffer).toString('base64') let event = { pubkey: ourPubKey, created_at: Math.floor(Date.now() / 1000), kind: 4, tags: [['p', theirPublicKey]], content: encryptedMessage + '?iv=' + ivBase64 }
The event still needs id and sig, computed with the serialization rule from NIP-01.
8 Version History
NIP-04 is finalized and no longer expected to change. Notable changes, newest first:
- 20252025-12Added the
relaycapability tag to the metadata line. No spec change. - 20242024-04Deprecation target switched from NIP-44 to the newly published NIP-17, which wraps NIP-44 in a full private-messaging design.
- 20232023-12NIP-44 published as a modern replacement encryption standard; NIP-04 marked
unrecommendedfor the first time. - 20232023-03 to 2023-04Added the security warning and the client mention-handling warning; fixed a bug and an import in the code sample; minor grammar fixes.
- 20232023-02Clarified that the ECDH secret is the raw, unhashed X coordinate of the shared point, not the libsecp256k1 default.
- 20222022-11Marked
final: no further spec changes expected. - Origin2022-05Migrated into the
nostr-protocol/nipsrepository, authored by arcbtc.
Original Proposal - Credits
| Author | arcbtc |
| First commit | 2022-05-01 — "migrate nips from main nostr repo" (attributed author:arcbtc in the original text) |
| Status | final - unrecommended - optional — deprecated in favor of NIP-17 |
| Source | 04.md on GitHub - full change history |
9 See Also
- NIP-17 — Private Direct Messages. The recommended replacement; hides sender, receiver, and kind from relays.
- NIP-44 — Encrypted Payloads (Versioned). The modern encryption primitive that NIP-17 builds on.
- NIP-59 — Gift Wrap. The event-wrapping technique NIP-17 uses to hide metadata that NIP-04 exposes.
- NIP-01 — base event format, serialization, and signing that every kind-4 event still follows.
- NIP-42 — relay
AUTH. Recommended when kind-4 events must still be used.