Chapter 10

Signing & login

Let apps use your key without ever seeing it.

Keep your secret key in your pocket! Today we learn how apps can borrow your signature without ever touching it.

Back in chapter 2 you learned that your Nostr identity is a keypair. The public key is your name tag; the secret key (shown as an nsec) is the pen that writes your signature on every event.

So here’s the puzzle: you want to use ten different Nostr apps. Every one of them needs your signature on the notes you post. Do you hand your pen to all ten?

Please don’t. Think of your secret key like the master key to your house. You wouldn’t give a copy to every delivery driver. You’d rather have a doorman who opens the door for them, checks what they’re carrying, and never lets the key out of his pocket. On Nostr, that doorman is called a signer.

Login without passwords

There are no accounts on Nostr, so there’s no password to type. When an app says “log in”, all it really needs is:

  1. Your public key, so it knows whose feed to show and whose notes to fetch.
  2. A way to get things signed, so your posts, likes and follows count as yours.

The dangerous shortcut is to give the app your nsec, which covers both at once. The safe way is to give it only number 1, plus a phone line to a signer for number 2.

Try it: who gets to touch your key?

Below is the same note signed three ways. Everything is real: real keys, real Schnorr signatures and real encryption running in your browser. The keys are demo keys made just for this page, so play freely.

Who gets to touch your key?

Same note, three ways to sign it. Pick a way, press sign, and watch the key-leak detector: it searches everything the app received for your secret key.

How should the app get a signature?

Demo keys only. Never paste a real nsec into any website — including this one.

The app (some website)

Everything the app received
  • Nothing yet.

What travelled

  1. Quiet so far.

Signer extension

Secret key locked in here

Waiting for a request…

Key-leak detector Not checked yet

Write a note and press “Sign & post”.

Things to try:

  • Sign with Paste your nsec and watch the key-leak detector go red. The app now is you.
  • Switch to Browser extension, sign, then press Reject in the signer. Nothing gets signed.
  • Try Remote bunker and open the hops in the middle column: the relay only ever carries gibberish.
Never paste a real nsec

A web page that holds your nsec can sign anything as you, today or years from now, and there is no “reset password” on Nostr. A leaked key is a lost identity (key loss is forever). This site will never ask for your real keys.

The browser extension (NIP-07)

The most common signer is a browser extension (popular ones include Alby, nos2x and Nostr Connect). It follows NIP-07, which is wonderfully small: the extension adds an object called window.nostr to every web page. A web app calls it like a phone:

  • “Who is this?” → getPublicKey() returns your public key. That’s the entire login.
  • “Please sign this note.” → signEvent(template) makes the extension pop up, show you the note, and, if you approve, hand back the finished, signed event.

The secret key lives inside the extension’s private storage. The web page can’t read it, just as one website can’t peek into another website’s cookies.

NIP-07: an app asks the browser extension to sign

The app talks to window.nostr, an object injected by the extension. The secret key stays inside the extension; the app only ever receives the public key and finished signatures.

YouWeb appSigner extensionRelay
Step 1 of 8

Ready. Press play or step forward to send the first message.

Text version
#FromToMessage
1Web appSigner extensionThe app asks the extension: who is logged in?
2Signer extensionWeb appThe extension answers with your public key. That's the whole “login”.
3Web appSigner extensionYou write a note. The app sends the unsigned template to the extension.
4Signer extensionYouThe extension pops up and shows you exactly what will be signed.
5YouSigner extensionYou approve. The secret key is used inside the extension only.
6Signer extensionWeb appThe extension returns the signed event: id, pubkey and sig added.
7Web appRelayThe app publishes the signed event to a relay. No key ever left the extension.

Step through both tabs. In the first one, notice that only finished things travel to the app: a public key and a signed event.

Under the hood: window.nostr Show details

NIP-07 defines two required methods and a few optional ones:

interface WindowNostr {
  getPublicKey(): Promise<string>; // hex pubkey
  signEvent(event: { created_at: number; kind: number; tags: string[][]; content: string }): Promise<Event>; // adds id, pubkey, sig
  nip44?: {
    encrypt(pubkey: string, plaintext: string): Promise<string>;
    decrypt(pubkey: string, ciphertext: string): Promise<string>;
  };
  nip04?: { /* same shape, deprecated */ };
}

A typical “Log in with extension” button:

if (!window.nostr) throw new Error("No NIP-07 signer found. Install a Nostr extension.");

const pubkey = await window.nostr.getPublicKey(); // the whole "login"

const signed = await window.nostr.signEvent({
  kind: 1,
  created_at: Math.floor(Date.now() / 1000),
  tags: [],
  content: "gm!",
}); // throws if the user clicks "Reject"

The app sends a template without pubkey, id or sig; the extension fills in all three. Extensions should inject window.nostr at document_end so it exists before your app’s scripts run. Always verify what comes back (e.g. verifyEvent from nostr-tools/pure) before you trust it.

The remote signer, or “bunker” (NIP-46)

Extensions are great on a laptop, but what about your phone, a shared computer, or an app that can’t install extensions? NIP-46 moves the signer to a different device, called a bunker. It could be an app on your phone, a little server at home, or a hosted service.

The clever part: the app and the bunker talk through an ordinary Nostr relay, like passing notes through a mailbox. Every note in the mailbox is locked with NIP-44 encryption, so the relay sees who is passing notes but not what they say.

  1. The bunker gives you a connection link: bunker://<bunker pubkey>?relay=wss://…&secret=….
  2. You paste it into the app. The app makes a throwaway keypair just for talking to the bunker.
  3. The app sends an encrypted connect request; the bunker checks the one-time secret and answers ack.
  4. The app sends get_public_key to learn your pubkey, which can differ from the bunker’s own key in the link.
  5. From then on, every sign_event request pops up on your phone. Approve, and the signed event comes back.

Flip the diagram above to the NIP-46 bunker tab and step through it. Open a hop’s details to compare what the relay sees with what the bunker decrypts.

Under the hood: kind 24133 and JSON-RPC Show details

NIP-46 is JSON-RPC inside encrypted events. Every request and response is a kind 24133 event, p-tagged to the other side, with NIP-44 encrypted content:

{
  "kind": 24133,
  "pubkey": "<client pubkey (throwaway)>",
  "tags": [["p", "<remote-signer pubkey>"]],
  "content": "<nip44(request)>",
  "created_at": 1735689600,
  "id": "…",
  "sig": "…"
}

Decrypted, a request and its response look like:

{ "id": "s1", "method": "sign_event", "params": ["{\"kind\":1,\"content\":\"gm\",\"tags\":[],\"created_at\":1735689600}"] }
{ "id": "s1", "result": "{\"kind\":1,…,\"id\":\"…\",\"pubkey\":\"…\",\"sig\":\"…\"}" }

Methods: connect, sign_event, ping, get_public_key, nip04_encrypt/nip04_decrypt, nip44_encrypt/nip44_decrypt, switch_relays and logout. Three different keypairs are in play: the client keypair (throwaway, lives in the app), the remote-signer keypair (the bunker’s mailbox address) and the user keypair (your identity, which never leaves the bunker). Always call get_public_key after connecting: the bunker’s pubkey is not necessarily your pubkey.

Connections can also start from the app with a nostrconnect://<client-pubkey>?relay=…&secret=… link that you scan with the bunker; the app then checks the returned secret to avoid spoofing. If the bunker needs extra login, it answers "result": "auth_url" with a URL in error for the app to open.

import { generateSecretKey } from "nostr-tools/pure";
import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46";

const pointer = await parseBunkerInput("bunker://…?relay=wss://relay.example&secret=…");
if (!pointer) throw new Error("Invalid bunker URL");

const clientKey = generateSecretKey(); // throwaway: store it to resume the session later
const bunker = BunkerSigner.fromBunker(clientKey, pointer, { onauth: (url) => window.open(url) });
await bunker.connect();

const pubkey = await bunker.getPublicKey(); // the user's key, not the bunker's
const signed = await bunker.signEvent({ kind: 1, created_at: Math.floor(Date.now() / 1000), tags: [], content: "gm" });
Which signer should I use?

On a desktop browser, a NIP-07 extension is the easiest. On a phone or across many devices, a NIP-46 bunker keeps one key safe in one place. Either way, the golden rule is the same: apps get signatures, never the key.

Names you can read: NIP-05

Public keys are great for computers and terrible for humans. Nobody wants to tell a friend “follow me at npub1qy88wumn8ghj7…”. NIP-05 adds an email-like label such as alice@alpha.example.

It works like a name badge that a company prints for you. Alice writes alice@alpha.example in her profile (kind 0 metadata). Your app doesn’t just believe her: it asks the domain alpha.example for a little file called nostr.json and checks that the name alice points to Alice’s public key. If the domain agrees, the app shows a checkmark.

Is this really alice@alpha.example?

Alice's profile (kind 0) claims a NIP-05 name. Your app double-checks it by asking the domain. Pick a claim, or type your own, then verify.

Try a claim

Alice's pubkey: e550c6e92808f358…

NIP-05 verification steps

Parse the address, build the well-known URL, fetch nostr.json from the domain, and compare the listed pubkey with the profile's pubkey.

  1. Split at @; a bare domain means _@domain.

  2. https://domain/.well-known/nostr.json?name=…

  3. Fetch nostr.json (demo: from a pretend internet).

  4. names[name] must equal the profile's pubkey (hex).

Stage 1: Parse name@domain. Split at @; a bare domain means _@domain.

Try Borrowed name: Alice’s profile claims to be bob@beta.example, but the domain lists Bob’s key there, so the check fails. Anyone can type any name into their profile; only the domain owner can vouch for it.

A badge, not an identity

A NIP-05 checkmark means “this domain vouches for this key”. It does not mean the person is famous, real, or trustworthy, and if the domain disappears, the badge does too. Your identity is still your key, which is why apps remember who you follow by pubkey, not by name.

Under the hood: /.well-known/nostr.json Show details

For alice@alpha.example, a client fetches https://alpha.example/.well-known/nostr.json?name=alice and expects:

{
  "names": { "alice": "<64-char lowercase hex pubkey>" },
  "relays": { "<that pubkey>": ["wss://relay.alpha.example"] }
}

Rules from NIP-05:

  • The local part may only use a-z0-9-_.; _@alpha.example is the “root” name and is displayed as just alpha.example.
  • Keys must be hex, never npub.
  • The endpoint must not redirect (clients must ignore redirects) and should send Access-Control-Allow-Origin: * so web apps can read it.
  • The optional relays map tells clients where to find that user.
import { queryProfile, isValid } from "nostr-tools/nip05";

const pointer = await queryProfile("alice@alpha.example"); // { pubkey, relays } | null
const ok = await isValid(profilePubkey, "alice@alpha.example"); // true if the domain agrees

Quick check

With a NIP-07 extension, what does the web app actually receive?

Choose one answer

In NIP-46, what can the relay in the middle read?

Choose one answer

What does a verified NIP-05 address like alice@alpha.example prove?

Choose one answer

Takeaways

  • On Nostr, “logging in” just means an app learns your public key and can ask for signatures.
  • Pasting your nsec into an app hands over your whole identity, forever. Use a signer instead.
  • NIP-07: a browser extension exposes window.nostr; apps get your pubkey and signed events, never the key.
  • NIP-46: a remote “bunker” signs on another device; requests travel as NIP-44 encrypted kind 24133 events through a relay.
  • NIP-05 (name@domain) is a domain vouching for a pubkey: a handy label, not your identity.