Nostr Intermediate 5 min read
Schnorr Signatures
Cryptographic signing in Nostr using BIP-340 Schnorr signatures. Sign events, verify authenticity, and understand the math.
signatures schnorr BIP-340 cryptography verification
Schnorr Signatures
Nostr uses BIP-340 Schnorr signatures for event authentication. Every event includes a signature proving it was created by the holder of the private key corresponding to the pubkey field.
Why Schnorr?
| Property | Benefit |
|---|---|
| Simplicity | Cleaner math than ECDSA |
| Efficiency | Faster verification |
| Linearity | Enables signature aggregation |
| Bitcoin-compatible | Same as Taproot signatures |
How Signing Works
Private Key + Message → Signature
│
▼
Public Key + Message + Signature → Valid/Invalid
Signing Process
- Create event without
idandsig - Serialize to canonical JSON
- SHA256 hash → event ID
- Sign ID with private key → signature
Verification Process
- Recalculate event ID from content
- Verify ID matches claimed ID
- Verify signature against ID and pubkey
Signing Events
Python (secp256k1)
from secp256k1 import PrivateKey
import hashlib
import json
def sign_event(event: dict, private_key_hex: str) -> dict:
"""Sign a Nostr event."""
private_key = PrivateKey(bytes.fromhex(private_key_hex))
# Derive public key (x-only, 32 bytes)
public_key = private_key.pubkey.serialize()[1:]
event["pubkey"] = public_key.hex()
# Serialize for hashing (NIP-01 format)
serialized = json.dumps([
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"]
], separators=(',', ':'), ensure_ascii=False)
# Calculate event ID (SHA256)
event_id = hashlib.sha256(serialized.encode()).digest()
event["id"] = event_id.hex()
# Create Schnorr signature (BIP-340)
signature = private_key.schnorr_sign(event_id, None)
event["sig"] = signature.hex()
return event
JavaScript (nostr-tools)
import { finalizeEvent } from 'nostr-tools';
// Create unsigned event
const unsignedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Hello, Nostr!'
};
// Sign (adds id, pubkey, sig)
const signedEvent = finalizeEvent(unsignedEvent, secretKey);
Using Noble Libraries
import { schnorr } from '@noble/curves/secp256k1';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
function signEvent(event, privateKey) {
// Serialize
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
// Hash
const eventId = sha256(new TextEncoder().encode(serialized));
event.id = bytesToHex(eventId);
// Sign
const sig = schnorr.sign(eventId, privateKey);
event.sig = bytesToHex(sig);
return event;
}
Verifying Signatures
Python
from secp256k1 import PublicKey
def verify_event(event: dict) -> tuple[bool, str]:
"""Verify a Nostr event's signature."""
# Step 1: Recalculate event ID
serialized = json.dumps([
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"]
], separators=(',', ':'), ensure_ascii=False)
expected_id = hashlib.sha256(serialized.encode()).hexdigest()
if event["id"] != expected_id:
return False, f"ID mismatch: expected {expected_id}"
# Step 2: Verify Schnorr signature
try:
# Add 02 prefix for even y-coordinate (BIP-340)
pubkey_bytes = bytes.fromhex("02" + event["pubkey"])
pubkey = PublicKey(pubkey_bytes, raw=True)
event_id_bytes = bytes.fromhex(event["id"])
sig_bytes = bytes.fromhex(event["sig"])
valid = pubkey.schnorr_verify(event_id_bytes, sig_bytes, None)
if not valid:
return False, "Invalid signature"
return True, "Valid"
except Exception as e:
return False, f"Verification error: {e}"
JavaScript
import { verifyEvent } from 'nostr-tools';
if (verifyEvent(event)) {
console.log('Event is valid');
} else {
console.log('Event is invalid or tampered');
}
Manual Verification with Noble
import { schnorr } from '@noble/curves/secp256k1';
import { sha256 } from '@noble/hashes/sha256';
function verifyEventManually(event) {
// Recalculate ID
const serialized = JSON.stringify([
0, event.pubkey, event.created_at,
event.kind, event.tags, event.content
]);
const calculatedId = bytesToHex(sha256(new TextEncoder().encode(serialized)));
if (calculatedId !== event.id) {
return { valid: false, reason: 'ID mismatch' };
}
// Verify signature
try {
const valid = schnorr.verify(
event.sig,
event.id,
event.pubkey
);
return { valid, reason: valid ? 'Valid' : 'Bad signature' };
} catch (e) {
return { valid: false, reason: e.message };
}
}
Signature Properties
Deterministic
Same message + same private key = same signature (when using RFC 6979 nonce generation).
Non-Malleable
BIP-340 signatures cannot be modified to create alternative valid signatures.
Compact
64 bytes (vs 70-72 for DER-encoded ECDSA).
Security Considerations
Nonce Generation
Critical: Use proper nonce generation. Bad nonces can leak private keys.
# GOOD: Library handles nonce generation
signature = private_key.schnorr_sign(message, None) # None = RFC 6979
# BAD: Never use predictable nonces
# signature = private_key.schnorr_sign(message, b"predictable") # NEVER!
Replay Protection
Events include timestamps. Relays may reject old events:
import time
def create_fresh_event(kind, content, tags=None):
return {
"created_at": int(time.time()), # Current timestamp
"kind": kind,
"tags": tags or [],
"content": content
}
Signature Validity
A valid signature proves:
- The signer holds the private key
- The content hasn’t been modified
- The signature was intentionally created
It does not prove:
- When the signature was made (beyond event timestamp)
- That the signer is who they claim to be
- That the content is true or accurate
Delegation (NIP-26)
Allow another key to sign on your behalf:
{
"tags": [
["delegation", "delegator-pubkey", "conditions", "delegation-sig"]
]
}
This is useful for:
- Agent keys with limited permissions
- Key rotation
- Multi-device setups
Common Errors
| Error | Cause | Fix |
|---|---|---|
| Invalid signature | Wrong key or tampered event | Verify key matches pubkey |
| ID mismatch | Serialization error | Check JSON serialization |
| Bad signature format | Not 64 bytes hex | Ensure 128 hex chars |
| Verification fails | Wrong pubkey format | Use x-only (32 bytes) |
Machine-Readable Summary
{
"topic": "nostr-signatures",
"audience": "ai-agents",
"prerequisites": ["secp256k1", "sha256", "nostr-keys"],
"key_concepts": [
"schnorr-signing",
"bip340-format",
"signature-verification",
"event-id-calculation"
],
"code_examples": ["python", "javascript"],
"security_notes": [
"use-proper-nonce-generation",
"never-share-private-keys",
"always-verify-events"
],
"related": [
"/learn/nostr/keys",
"/learn/nostr/events",
"/learn/bitcoin/specs/bip-340"
]
}