Why Your Nostr Relay Can Still Link You to Every Note You Sign
Nostr has no accounts and no passwords, so it feels anonymous. It isn't. Every note you publish carries your public key and a signature, which means anyone holding two of your notes can tell they came from the same key without any effort at all.
That is the design working as intended. The trouble is that people hear "no sign-up, no email" and conclude "no identity". The identity is the key, and the key is stamped on everything.
What a signed note actually contains
An event in Nostr (NIP-01) is a small JSON object. The fields that matter here:
pubkey: your 32-byte public key, hex encodedcreated_at: a Unix timestamp you chosekind,tags,content: what you are sayingid: a SHA-256 hash of the other fieldssig: a Schnorr signature over that id
The signature exists so that anyone can verify a note came from that key, whichever relay they got it from. That is exactly the property that lets a relay (or a stranger) group your notes by author.
The id is just a hash you can compute yourself
The id is the SHA-256 of a JSON array: [0, pubkey, created_at, kind, tags, content], serialised with no extra whitespace. Here it is in Go.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
)
func eventID(pubkey string, createdAt int64, kind int, tags [][]string, content string) (string, error) {
if tags == nil {
tags = [][]string{} // must serialise as [], not null
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode([]any{0, pubkey, createdAt, kind, tags, content}); err != nil {
return "", err
}
sum := sha256.Sum256(bytes.TrimRight(buf.Bytes(), "\n"))
return hex.EncodeToString(sum[:]), nil
}
func main() {
id, err := eventID("aa", 1700000000, 1, nil, "hello & goodbye")
if err != nil {
log.Fatal(err)
}
fmt.Println(id)
}
Quick detour, because two lines there are traps. Go's json.Marshal escapes <, > and & as < and friends by default, which gives a different hash from the one NIP-01 expects. Disabling HTML escaping fixes that. The encoder also appends a newline, so it gets trimmed. This is a sketch: it does not implement every escaping rule in the spec, and it does no signing.
The key is only half the story
Even if you ignored the pubkey, the relay sees more than the note. Over a WebSocket connection it learns:
- your IP address (or your VPN or Tor exit's)
- when you connect and when you publish
- what you subscribe to, because a
REQmessage carries filters naming authors, kinds and tags - your client's connection behaviour, which differs between apps
So the relay can say not just "this key wrote these notes" but "this IP, at these times, also asked for that other person's notes". Reading leaves a trail as well as writing. A relay that wants to build a profile does not need to break any cryptography to do it.
Using a fresh key does not fully help
The tempting fix is a throwaway key per topic. It does break the cryptographic link, which is useful. It does not break the others.
- The same IP publishing a minute apart from two keys is an obvious pair.
- Your writing style, posting hours and the people you reply to leak through the content.
- If any key was ever tied to your real name (a NIP-05 address, a profile link, a tip jar), the whole cluster follows.
This is the same shape as other metadata problems: the content protection is solid, and the surrounding facts do the identifying. Hiding the payload is easy; hiding who is holding the connection is hard.
What does reduce the linkage
None of these make you anonymous. Each removes one thread:
- Connect through Tor or a proxy so the relay never sees your home IP. Use a separate circuit or exit for each identity if you keep several.
- Use separate keys for identities you want kept apart, and never cross-post or reply between them.
- Avoid public timing patterns: you choose
created_at, so clients can jitter or batch it, though relays still see when the connection arrives. - Be wary of authenticated relays: NIP-42 has the client sign a challenge to prove its key, which hands the relay a strong link between connection and key. Sometimes that is exactly what a private relay needs; just know what you are giving it.
- Prefer fewer relays for reads you want private, since each relay you query sees your filters.
Deletion will not rescue you either
A signed event can be copied by any relay or client that sees it. NIP-09 lets you publish a deletion request, but it is a request, and a relay that never honours it, or already forwarded the note, keeps the copy. Treat anything you sign as published for good.
The honest model: a Nostr key is a stable pseudonym, and a relay is a witness that can also see your network address. Design your use around that, and the surprise goes away.