window.nostr capability for web browsers
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.
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.nostrAPI - 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
- Web-based Nostr Clients: Sign events without managing keys
- Nostr-enabled Websites: Add Nostr login/posting
- Decentralized Apps: Interact with Nostr protocol
- Browser Extensions: Standard API for key management
- Cross-client Identity: Same keys across all web clients
- 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
pubkeyfield - Add
created_atif 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;
}
Popular Extensions
Extension Comparison
| Extension | Platform | Features | Open Source |
|---|---|---|---|
| Alby | Chrome, Firefox | Lightning wallet, NIP-07, WebLN | ✅ |
| nos2x | Chrome | Minimal, NIP-07 only | ✅ |
| Flamingo | Chrome | UI-focused, multiple accounts | ✅ |
| Nostore | Chrome, Firefox | Privacy-focused | ✅ |
| Spring | Chrome | Social features | ⚠️ |
Installing Extensions
Alby (Recommended for Lightning support):
- Visit https://getalby.com
- Click “Install Extension”
- Create account or import keys
- Grant permissions to websites
nos2x (Minimal, privacy-focused):
- Visit Chrome Web Store
- Search “nos2x”
- Click “Add to Chrome”
- 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.
Related NIPs
- 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:
- Learn about Proof of Work in NIP-13
- Explore URI schemes in NIP-21
- Understand remote signing in NIP-46
- Browse all NIPs in our reference
Last updated: November 2023 Official specification: GitHub
Client Support
This NIP is supported by the following clients: