Menu
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?

PropertyBenefit
SimplicityCleaner math than ECDSA
EfficiencyFaster verification
LinearityEnables signature aggregation
Bitcoin-compatibleSame as Taproot signatures

How Signing Works

Private Key + Message → Signature


Public Key + Message + Signature → Valid/Invalid

Signing Process

  1. Create event without id and sig
  2. Serialize to canonical JSON
  3. SHA256 hash → event ID
  4. Sign ID with private key → signature

Verification Process

  1. Recalculate event ID from content
  2. Verify ID matches claimed ID
  3. 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:

  1. The signer holds the private key
  2. The content hasn’t been modified
  3. 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

ErrorCauseFix
Invalid signatureWrong key or tampered eventVerify key matches pubkey
ID mismatchSerialization errorCheck JSON serialization
Bad signature formatNot 64 bytes hexEnsure 128 hex chars
Verification failsWrong pubkey formatUse 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"
  ]
}