NIP-07

window.nostr capability for web browsers

draft application

NIP-07 enables browser extensions to expose a window.nostr API for signing Nostr events, allowing web apps to interact with user keys securely without direct access.

Author
fiatjaf
Last Updated
20 November 2023
Official Spec
View on GitHub →

NIP-07: window.nostr capability for web browsers

Status: Draft Author: fiatjaf Category: Application


Overview

NIP-07 defines the window.nostr API that browser extensions provide to web applications, enabling secure event signing without exposing private keys.

Core Concept:

  • Browser extension manages private keys
  • Web apps request signing via window.nostr API
  • User approves/denies each request
  • Private keys never exposed to websites

Key Methods:

  • getPublicKey() - Get user’s public key
  • signEvent(event) - Sign an event
  • getRelays() - Get user’s relay list
  • nip04.encrypt() / nip04.decrypt() - DM encryption

Status: Draft (widely adopted, de facto standard)


Why window.nostr Matters

Security Problem

Without NIP-07:

  • Web apps need direct access to private keys
  • Users paste private keys into websites (risky!)
  • Private keys exposed to malicious websites
  • No permission system for signing

With NIP-07:

  • Keys stay in extension (never exposed)
  • User approval required for each signing request
  • Permission management per website
  • Multiple extensions available (user choice)

Use Cases

  1. Web-based Nostr Clients: Sign events without managing keys
  2. Nostr-enabled Websites: Add Nostr login/posting
  3. Decentralized Apps: Interact with Nostr protocol
  4. Browser Extensions: Standard API for key management
  5. Cross-client Identity: Same keys across all web clients
  6. Enhanced Security: Hardware wallet support via extensions

How It Works

API Flow

Web App                 window.nostr (Extension)         User
   |                            |                         |
   |-- getPublicKey() --------->|                         |
   |                            |---- Permission UI ----->|
   |                            |<--- Approve/Deny -------|
   |<--- pubkey ----------------|                         |
   |                            |                         |
   |-- signEvent(event) ------->|                         |
   |                            |---- Signing UI -------->|
   |                            |<--- Approve/Deny -------|
   |<--- signed event ----------|                         |

API Reference

Detecting Extension

async function hasNostrExtension() {
  // Check if window.nostr exists
  if (window.nostr) {
    return true;
  }

  // Some extensions load asynchronously
  await new Promise(resolve => setTimeout(resolve, 100));
  return !!window.nostr;
}

// Usage
if (await hasNostrExtension()) {
  console.log("Nostr extension detected!");
} else {
  console.log("No extension found. Please install one.");
}

getPublicKey()

Get user’s public key (hex format):

async function getPublicKey() {
  try {
    const pubkey = await window.nostr.getPublicKey();
    console.log("Public key:", pubkey);
    return pubkey;
  } catch (error) {
    console.error("User denied public key access:", error);
    return null;
  }
}

// Usage
const pubkey = await getPublicKey();
if (pubkey) {
  console.log("Logged in as:", pubkey);
}

Returns: Promise<string> - 64-character hex public key

May Throw: User can deny the request


signEvent(event)

Sign an event object:

async function signEvent(event) {
  try {
    // Extension adds id, pubkey, sig, created_at
    const signedEvent = await window.nostr.signEvent(event);
    console.log("Event signed:", signedEvent);
    return signedEvent;
  } catch (error) {
    console.error("User denied signing:", error);
    return null;
  }
}

// Usage: Create and sign a text note
const event = {
  kind: 1,
  content: "Hello from my web app!",
  tags: [],
  created_at: Math.floor(Date.now() / 1000)
};

const signedEvent = await signEvent(event);
if (signedEvent) {
  // Publish to relays
  await publishToRelays(signedEvent);
}

Parameters:

  • event - Partial event object (kind, content, tags)

Returns: Promise<SignedEvent> - Complete event with id, pubkey, sig

Extension Responsibilities:

  • Add pubkey field
  • Add created_at if missing
  • Calculate id (event hash)
  • Generate sig (signature)

getRelays()

Get user’s relay list (deprecated, use NIP-65 instead):

async function getRelays() {
  try {
    const relays = await window.nostr.getRelays();
    console.log("User relays:", relays);
    return relays;
  } catch (error) {
    console.error("Failed to get relays:", error);
    return {};
  }
}

// Usage
const relays = await getRelays();
// Returns: { "wss://relay.damus.io": { read: true, write: true }, ... }

// Connect to user's relays
for (const [url, policy] of Object.entries(relays)) {
  if (policy.read) {
    await connectToRelay(url);
  }
}

Returns: Promise<RelayPolicy> - Object mapping relay URLs to read/write permissions

Format:

{
  "wss://relay.damus.io": { read: true, write: true },
  "wss://relay.primal.net": { read: true, write: false },
  "wss://nos.lol": { read: false, write: true }
}

Note: This method is deprecated. Use NIP-65 relay list events instead.


nip04.encrypt()

Encrypt a message for DMs (NIP-04):

async function encryptMessage(recipientPubkey, plaintext) {
  try {
    const ciphertext = await window.nostr.nip04.encrypt(
      recipientPubkey,
      plaintext
    );
    return ciphertext;
  } catch (error) {
    console.error("Encryption failed:", error);
    return null;
  }
}

// Usage: Send encrypted DM
const recipientPubkey = "recipient-public-key-hex";
const message = "This is a private message";

const encrypted = await encryptMessage(recipientPubkey, message);

if (encrypted) {
  const dmEvent = {
    kind: 4,
    content: encrypted,
    tags: [["p", recipientPubkey]],
    created_at: Math.floor(Date.now() / 1000)
  };

  const signed = await window.nostr.signEvent(dmEvent);
  await publishToRelays(signed);
}

Parameters:

  • pubkey - Recipient’s public key (hex)
  • plaintext - Message to encrypt

Returns: Promise<string> - Encrypted ciphertext


nip04.decrypt()

Decrypt a received DM:

async function decryptMessage(senderPubkey, ciphertext) {
  try {
    const plaintext = await window.nostr.nip04.decrypt(
      senderPubkey,
      ciphertext
    );
    return plaintext;
  } catch (error) {
    console.error("Decryption failed:", error);
    return null;
  }
}

// Usage: Decrypt received DM
const dmEvent = {
  pubkey: "sender-pubkey-hex",
  kind: 4,
  content: "encrypted-content-here..."
};

const decrypted = await decryptMessage(dmEvent.pubkey, dmEvent.content);
console.log("Message:", decrypted);

Parameters:

  • pubkey - Sender’s public key (hex)
  • ciphertext - Encrypted content to decrypt

Returns: Promise<string> - Decrypted plaintext


Complete Web App Integration

Nostr Login Button

function NostrLoginButton() {
  const [pubkey, setPubkey] = useState(null);
  const [loading, setLoading] = useState(false);

  async function handleLogin() {
    // Check for extension
    if (!window.nostr) {
      alert("Please install a Nostr extension (Alby, nos2x, etc.)");
      return;
    }

    setLoading(true);

    try {
      const userPubkey = await window.nostr.getPublicKey();
      setPubkey(userPubkey);
      console.log("Logged in:", userPubkey);

      // Fetch user profile
      await fetchUserProfile(userPubkey);
    } catch (error) {
      console.error("Login failed:", error);
      alert("Login cancelled or failed");
    } finally {
      setLoading(false);
    }
  }

  if (pubkey) {
    return <div>Logged in as: {pubkey.slice(0, 8)}...</div>;
  }

  return (
    <button onClick={handleLogin} disabled={loading}>
      {loading ? "Connecting..." : "Login with Nostr"}
    </button>
  );
}

Post to Nostr

async function postToNostr(content) {
  if (!window.nostr) {
    throw new Error("No Nostr extension found");
  }

  // Create event
  const event = {
    kind: 1,
    content: content,
    tags: [],
    created_at: Math.floor(Date.now() / 1000)
  };

  // Sign with extension
  const signedEvent = await window.nostr.signEvent(event);

  // Publish to relays
  const relays = await getDefaultRelays();
  const results = await Promise.all(
    relays.map(relay => publishEvent(relay, signedEvent))
  );

  return signedEvent;
}

// Usage component
function PostForm() {
  const [content, setContent] = useState("");
  const [posting, setPosting] = useState(false);

  async function handlePost() {
    if (!content.trim()) return;

    setPosting(true);
    try {
      await postToNostr(content);
      alert("Posted successfully!");
      setContent("");
    } catch (error) {
      console.error("Post failed:", error);
      alert("Failed to post: " + error.message);
    } finally {
      setPosting(false);
    }
  }

  return (
    <div>
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="What's happening?"
      />
      <button onClick={handlePost} disabled={posting}>
        {posting ? "Posting..." : "Post"}
      </button>
    </div>
  );
}

Full Nostr Web Client

class NostrClient {
  constructor() {
    this.pubkey = null;
    this.relays = [];
  }

  async init() {
    if (!window.nostr) {
      throw new Error("Nostr extension not found");
    }

    // Get public key
    this.pubkey = await window.nostr.getPublicKey();

    // Get relays (fallback to defaults if not available)
    try {
      const relayPolicy = await window.nostr.getRelays();
      this.relays = Object.entries(relayPolicy)
        .filter(([_, policy]) => policy.read)
        .map(([url, _]) => url);
    } catch {
      this.relays = [
        "wss://relay.damus.io",
        "wss://relay.primal.net",
        "wss://nos.lol"
      ];
    }

    return this;
  }

  async publishNote(content, tags = []) {
    const event = {
      kind: 1,
      content,
      tags,
      created_at: Math.floor(Date.now() / 1000)
    };

    return await window.nostr.signEvent(event);
  }

  async sendDM(recipientPubkey, message) {
    const encrypted = await window.nostr.nip04.encrypt(
      recipientPubkey,
      message
    );

    const event = {
      kind: 4,
      content: encrypted,
      tags: [["p", recipientPubkey]],
      created_at: Math.floor(Date.now() / 1000)
    };

    return await window.nostr.signEvent(event);
  }

  async decryptDM(senderPubkey, encryptedContent) {
    return await window.nostr.nip04.decrypt(senderPubkey, encryptedContent);
  }
}

// Usage
const client = await new NostrClient().init();
const note = await client.publishNote("Hello Nostr!");
const dm = await client.sendDM("recipient-pubkey", "Secret message");

Browser Extension Development

Basic Extension Structure

// background.js - Extension background script
class NostrExtension {
  constructor() {
    this.privateKey = null;
    this.pubkey = null;
    this.permissions = new Map(); // domain -> permissions
  }

  async init() {
    // Load keys from secure storage
    const stored = await chrome.storage.local.get(['privateKey']);
    if (stored.privateKey) {
      this.privateKey = stored.privateKey;
      this.pubkey = getPublicKey(this.privateKey);
    }
  }

  async handleGetPublicKey(domain) {
    // Check permissions
    if (!this.hasPermission(domain, 'getPublicKey')) {
      const granted = await this.requestPermission(domain, 'getPublicKey');
      if (!granted) {
        throw new Error("Permission denied");
      }
    }

    return this.pubkey;
  }

  async handleSignEvent(domain, event) {
    // Check permissions
    if (!this.hasPermission(domain, 'signEvent')) {
      const granted = await this.requestPermission(domain, 'signEvent', event);
      if (!granted) {
        throw new Error("Permission denied");
      }
    }

    // Add required fields
    event.pubkey = this.pubkey;
    event.created_at = event.created_at || Math.floor(Date.now() / 1000);

    // Calculate ID
    event.id = getEventHash(event);

    // Sign
    event.sig = signEvent(event, this.privateKey);

    return event;
  }

  hasPermission(domain, action) {
    const perms = this.permissions.get(domain);
    return perms && perms[action];
  }

  async requestPermission(domain, action, data) {
    // Show permission popup
    return new Promise((resolve) => {
      chrome.windows.create({
        url: `popup.html?action=${action}&domain=${domain}`,
        type: 'popup',
        width: 400,
        height: 600
      }, (window) => {
        // Wait for user response
        chrome.runtime.onMessage.addListener((message) => {
          if (message.type === 'permission-response') {
            resolve(message.granted);
          }
        });
      });
    });
  }
}

const extension = new NostrExtension();
extension.init();

Content Script Injection

// content.js - Injected into web pages
(function() {
  // Inject window.nostr API
  window.nostr = {
    async getPublicKey() {
      return await sendToBackground('getPublicKey');
    },

    async signEvent(event) {
      return await sendToBackground('signEvent', event);
    },

    async getRelays() {
      return await sendToBackground('getRelays');
    },

    nip04: {
      async encrypt(pubkey, plaintext) {
        return await sendToBackground('nip04.encrypt', { pubkey, plaintext });
      },

      async decrypt(pubkey, ciphertext) {
        return await sendToBackground('nip04.decrypt', { pubkey, ciphertext });
      }
    }
  };

  function sendToBackground(method, params) {
    return new Promise((resolve, reject) => {
      chrome.runtime.sendMessage(
        { method, params, domain: window.location.hostname },
        (response) => {
          if (response.error) {
            reject(new Error(response.error));
          } else {
            resolve(response.result);
          }
        }
      );
    });
  }
})();

Security Considerations

Permission System

class PermissionManager {
  constructor() {
    this.permissions = new Map();
  }

  async requestPermission(domain, action, duration = 'session') {
    // Show user confirmation dialog
    const granted = await this.showPermissionDialog(domain, action);

    if (granted) {
      this.permissions.set(domain, {
        ...this.permissions.get(domain),
        [action]: {
          granted: true,
          duration: duration,
          grantedAt: Date.now()
        }
      });
    }

    return granted;
  }

  hasPermission(domain, action) {
    const perms = this.permissions.get(domain);
    if (!perms || !perms[action]) return false;

    // Check if expired
    if (perms[action].duration === 'session') {
      return true;
    } else if (perms[action].duration === 'temporary') {
      const elapsed = Date.now() - perms[action].grantedAt;
      return elapsed < 3600000; // 1 hour
    }

    return false;
  }

  revokePermission(domain, action = null) {
    if (action) {
      const perms = this.permissions.get(domain);
      if (perms) {
        delete perms[action];
      }
    } else {
      this.permissions.delete(domain);
    }
  }
}

Event Validation

function validateEventBeforeSigning(event) {
  // Check required fields
  if (!event.kind || typeof event.kind !== 'number') {
    throw new Error("Invalid or missing 'kind'");
  }

  if (typeof event.content !== 'string') {
    throw new Error("Invalid or missing 'content'");
  }

  if (!Array.isArray(event.tags)) {
    throw new Error("Invalid or missing 'tags'");
  }

  // Check for suspicious content
  if (event.kind === 5 && event.tags.length > 100) {
    throw new Error("Suspicious: Attempting to delete too many events");
  }

  // Warn on sensitive event types
  if (event.kind === 3) {
    return confirm("This will update your contact list. Continue?");
  }

  return true;
}

Extension Comparison

ExtensionPlatformFeaturesOpen Source
AlbyChrome, FirefoxLightning wallet, NIP-07, WebLN
nos2xChromeMinimal, NIP-07 only
FlamingoChromeUI-focused, multiple accounts
NostoreChrome, FirefoxPrivacy-focused
SpringChromeSocial features⚠️

Installing Extensions

Alby (Recommended for Lightning support):

  1. Visit https://getalby.com
  2. Click “Install Extension”
  3. Create account or import keys
  4. Grant permissions to websites

nos2x (Minimal, privacy-focused):

  1. Visit Chrome Web Store
  2. Search “nos2x”
  3. Click “Add to Chrome”
  4. Import your private key

Client Support

Web Clients Using NIP-07

  • Snort - Full-featured web client
  • Iris - Social network interface
  • Coracle - Advanced features
  • Nostrudel - Power user tools
  • Nostribe - Communities focus
  • Habla - Long-form content

All major web clients support NIP-07 for authentication.


Common Questions

Is window.nostr secure?

Yes, if used correctly. Your private key stays in the extension and never gets exposed to websites. However, you should:

  • Only use trusted extensions (open source, audited)
  • Review permissions before granting
  • Revoke permissions for untrusted sites

Can malicious websites steal my keys?

No. The extension never exposes private keys to websites. Malicious sites can only request signing, which you must approve.

What if I deny a signing request?

The promise rejects with an error. Web apps should handle this gracefully.

Can I use multiple extensions?

Technically yes, but only one can provide window.nostr at a time. The last loaded extension wins.

Do I need an extension for mobile?

No. Mobile apps typically manage keys directly. NIP-07 is browser-specific.

What about hardware wallets?

Some extensions (like Alby) support hardware wallet integration, keeping keys even more secure.


  • NIP-01 - Basic protocol (event structure, signatures)
  • NIP-04 - Encrypted Direct Messages (nip04 methods)
  • NIP-44 - Encrypted Payloads v2 (future encryption)
  • NIP-46 - Remote signing (alternative to NIP-07)

Technical Specification

For the complete technical specification, see NIP-07 on GitHub.


Summary

NIP-07 enables secure key management in browsers:

window.nostr API for web apps ✅ Private keys stay in extension (never exposed) ✅ User permission required for each action ✅ Standard methods: getPublicKey, signEvent, getRelays, nip04 ✅ Multiple extensions available (Alby, nos2x, Flamingo)

API example:

// Check for extension
if (window.nostr) {
  // Get public key
  const pubkey = await window.nostr.getPublicKey();

  // Sign event
  const signed = await window.nostr.signEvent({
    kind: 1,
    content: "Hello Nostr!",
    tags: []
  });
}

Status: Draft - widely adopted, de facto standard for web clients.

Best practice: Always check for window.nostr before using, handle rejections gracefully.


Next Steps:


Last updated: November 2023 Official specification: GitHub

Client Support

This NIP is supported by the following clients:

alby nos2x flamingo nostore spring
View all clients →

Related NIPs

NIP-01 NIP-04 NIP-44 NIP-46
← Browse All NIPs