Integration Guide

Bunker 101 — How NSE Connects

From key protection to login flow. The complete picture.

NSE protects keys. But keys need to be used — for signing events, proving identity, authorizing actions. This guide shows how NSE fits into the three ways Nostr signing actually works in practice.

What is a Bunker?

If you've never heard the term "bunker" in Nostr context, here's the short version: a bunker is a signer that holds your private key and signs on your behalf. The app that needs a signature never sees the key. It sends a signing request, gets back a signature.

The communication happens over Nostr relays using kind 24133 ephemeral events, encrypted end-to-end. The relay is just a message bus — it can't read the requests or responses.

Think of it like SSH agent forwarding. Your SSH key stays on one machine. When another machine needs to authenticate, the agent signs on its behalf. The key never moves. Same idea, different protocol.

NIP-46 defines this protocol. The methods are straightforward: sign_event, get_public_key, connect, nip04_encrypt, nip04_decrypt, and a few more. The signer implements them. The client calls them. The relay carries the messages.

Where NSE Fits

This is the important distinction: NSE is not the bunker. NSE is what the bunker uses internally to protect the key at rest.

Without NSE: Bunker stores nsec in plaintext → device compromised → key gone With NSE: Bunker stores encrypted blob → device compromised → attacker gets ciphertext, not key

NSE is the lock on the vault door. The bunker is the vault. The relay is the hallway between rooms. Three different things, each with its own job.

Your application — whether it's a browser extension, a phone app, or a server process — is the bunker. It uses NSE to decrypt the key only when it needs to sign, then zeros the plaintext immediately after. The rest of the time, the key is AES-256-GCM ciphertext protected by hardware.

Three Patterns

There are three ways signing works in practice. Which one you use depends on where the signer lives relative to the app that needs signatures.

Pattern A

Local Signing (NIP-07)

When: Browser extension, same device, no network needed.

The simplest pattern. Your browser extension IS the signer. A web app calls window.nostr.signEvent(event) — that's the NIP-07 standard. The extension receives the request, uses NSE to decrypt the key, signs the event with Schnorr, zeros the plaintext, and returns the signature. Done.

The Nostr network is not involved in the signing. Relays carry the signed event after — but the act of signing is entirely local. No relay, no network, no discovery. Just local IPC.

Web App Browser Extension │ │ ├── window.nostr.signEvent() ──→│ │ ├── NSEBrowser.sign(event) │ │ ├── Decrypt secp256k1 key │ │ ├── Schnorr sign │ │ └── Zero plaintext │←── signed event ─────────────⊣ │ │ ├── Publish to relay ────────→ Nostr Network

Extension side (the signer)

// In your browser extension's background script
import { NSEBrowser, NSEIndexedDBStorage } from 'nostr-secure-enclave-browser';

const nse = new NSEBrowser({ storage: new NSEIndexedDBStorage() });

// Handle NIP-07 signing requests
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'signEvent') {
    nse.sign(msg.event).then(signed => sendResponse(signed));
    return true; // async response
  }
});

Web app side (the requester)

// In your web app — the requesting side
const signed = await window.nostr.signEvent({
  kind: 1,
  content: 'Hello Nostr!',
  tags: [],
  created_at: Math.floor(Date.now() / 1000),
});

// signed.id, signed.sig, signed.pubkey are populated

Key point: No relay. No network. No discovery. Just local IPC. This is how NostrKey works.

Pattern B

Remote Signing — Your Relay

When: Phone signs for desktop. You control both ends and the relay.

The phone has the key (protected by NSE + Secure Enclave or StrongBox). The desktop app needs a signature. Both connect to your relay — say relay.nostrkeep.com. The desktop sends a NIP-46 signing request through the relay. The phone receives it, decrypts the key with NSE, signs, and sends the signature back through the relay.

No bunker:// URI parsing. No public relay discovery. No hoping some random relay is online. You built both apps, you know where to connect.

Desktop App Your Relay Phone (NSE + SE) │ │ │ ├── NIP-46 request ───────→│ │ │ (kind 24133) ├── forward ──────────────→│ │ │ ├── Biometric unlock │ │ ├── NSE.sign(event) │ │ │ ├── Decrypt key │ │ │ ├── Schnorr sign │ │ │ └── Zero plaintext │ │←── NIP-46 response ──────⊣ │←── signature ────────────⊣ (kind 24133) │ │ │ │

Signer side (phone or server)

// NIP-46 signer — the side that holds the key
import { NSEServer } from 'nostr-secure-enclave-server';
// On mobile: import NSE from 'nostr-secure-enclave-ios'
// or: import NSE from 'nostr-secure-enclave-android'

const nse = new NSEServer({ masterKey: process.env.NSE_MASTER_KEY, storage });

// Connect to relay, listen for NIP-46 requests
const ws = new WebSocket('wss://relay.nostrkeep.com');

ws.on('message', async (data) => {
  const msg = JSON.parse(data);
  // Decrypt NIP-46 request (NIP-04 or NIP-44 encrypted)
  const request = decryptNip46Request(msg);

  if (request.method === 'sign_event') {
    const signed = await nse.sign(request.params[0]);
    const response = encryptNip46Response(signed);
    ws.send(JSON.stringify(['EVENT', response]));
  }

  if (request.method === 'get_public_key') {
    const pubkey = await nse.getPublicKey();
    const response = encryptNip46Response(pubkey);
    ws.send(JSON.stringify(['EVENT', response]));
  }
});

Client side (desktop app)

// NIP-46 client — the side that needs signatures
const ws = new WebSocket('wss://relay.nostrkeep.com');

async function requestSignature(event) {
  // Send NIP-46 signing request through relay
  const request = createNip46Request('sign_event', [event]);
  ws.send(JSON.stringify(['EVENT', request]));

  // Wait for response
  return new Promise(resolve => {
    ws.on('message', (data) => {
      const response = parseNip46Response(data);
      if (response) resolve(response);
    });
  });
}

const signed = await requestSignature({
  kind: 1,
  content: 'Signed from desktop, key on phone!',
  tags: [],
  created_at: Math.floor(Date.now() / 1000),
});

Key point: You own the relay. Both sides know where to connect. No discovery protocol needed. This is how NostrKeep Signer works with the browser extension.

Pattern C

Any Relay — Open NIP-46

When: Third-party apps, general-purpose bunker signing, interoperability.

This is standard NIP-46 as the protocol designers intended. The signer advertises which relay to use via a bunker:// URI:

bunker://<signer-pubkey>?relay=wss://relay.example.com&secret=<optional-secret>

Any NIP-01 relay that supports ephemeral events works. The client parses the URI, connects to the specified relay, and follows the NIP-46 handshake. NSE still protects the key on the signer side — the relay just carries encrypted messages.

Any Nostr App Any Relay NSE-powered Signer │ │ │ ├── bunker:// connect ────→│──────────────────────────→│ │←── ack ──────────────────⊣←──────────────────────────⊣ ├── sign_event request ──→│──────────────────────────→│ │ │ NSE.sign() │←── signed event ─────────⊣←──────────────────────────⊣

Key point: This is the fully decentralized version. Any app can request signatures from any NSE-powered signer through any relay. Maximum interoperability, but requires relay discovery via bunker:// URIs.

Choosing Your Pattern

Question Pattern
Signer and app on the same device? A — Local (NIP-07)
You control both apps + the relay? B — Your Relay
Third-party apps or general interop? C — Any Relay (NIP-46)

Most products start with Pattern A (browser extension) and add Pattern B (mobile bunker) later. Pattern C is for when you want your signer to work with the broader Nostr ecosystem — any app, any relay.

The good news: NSE works identically in all three patterns. The nse.sign(event) call doesn't know or care how the signing request arrived. It just decrypts, signs, and zeros.

The Relay Question

"Do I need to run a relay?" is the most common question. The answer depends on your pattern:

The relay does not see keys. It only sees encrypted NIP-46 messages (kind 24133, ephemeral). Even a compromised relay can't extract signatures or keys — it's just passing encrypted envelopes. The encryption is end-to-end between the client and the signer, using NIP-04 or NIP-44.

What You're Building On

Here's how all the pieces fit together, from your application down to hardware:

┌─────────────────────────────────────────────┐ │ Your Application │ │ (browser extension, mobile app, bot) │ └─────────────────────────────────────────────┘ │ ┌─────────────────────────┐ │ │ │ ┌────&boxdash;────┐ ┌────&boxdash;─────┐ ┌─────&boxdash;──────┐ │ NIP-07 │ │ NIP-46 │ │ Direct │ │ (local) │ │ (relay) │ │ (library) │ └────&boxdash;────┘ └────&boxdash;─────┘ └─────&boxdash;──────┘ │ │ │ └─────────────────────────┘ │ ┌───────────&boxdash;───────────┐ │ NSE │ │ Decrypt → Sign → Zero │ └───────────&boxdash;───────────┘ │ ┌───────────&boxdash;───────────┐ │ Hardware Protection │ │ SE / StrongBox / TPM / │ │ SubtleCrypto / KMS │ └─────────────────────────┘

Direct (library) means calling nse.sign() directly in your code — no NIP-07, no NIP-46. This is what bots, AI entities, and server processes use. Same NSE underneath, just no signing protocol layer on top.

The nostr-crypto-utils library includes NIP-46 client and server-side signer primitives if you need a head start on the encryptNip46Request / decryptNip46Response functions shown above.

Next Steps

Pick a pattern and start building.

Start with Pattern A — install nostr-secure-enclave-browser and protect keys in your extension.

Add Pattern B — install nostr-secure-enclave-ios or nostr-secure-enclave-android, connect through your relay.

Go full NIP-46 — your signer works with any Nostr app through any relay. Maximum interoperability.

GitHub npm