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
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
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:
- Your public key, so it knows whose feed to show and whose notes to fetch.
- 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
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.
The app (some website)
Everything the app received
- Nothing yet.
What travelled
- Quiet so far.
Signer extension
Waiting for a request…
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.
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
(
The browser extension (NIP-07)
The most common signer is a browser extension (popular ones include Alby, nos2x and Nostr Connect).
It follows 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.
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.
Ready. Press play or step forward to send the first message.
Text version
| # | From | To | Message |
|---|---|---|---|
| 1 | Web app | Signer extension | The app asks the extension: who is logged in? |
| 2 | Signer extension | Web app | The extension answers with your public key. That's the whole “login”. |
| 3 | Web app | Signer extension | You write a note. The app sends the unsigned template to the extension. |
| 4 | Signer extension | You | The extension pops up and shows you exactly what will be signed. |
| 5 | You | Signer extension | You approve. The secret key is used inside the extension only. |
| 6 | Signer extension | Web app | The extension returns the signed event: id, pubkey and sig added. |
| 7 | Web app | Relay | The 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?
The clever part: the app and the bunker talk through an ordinary Nostr
- The bunker gives you a connection link:
bunker://<bunker pubkey>?relay=wss://…&secret=…. - You paste it into the app. The app makes a throwaway keypair just for talking to the bunker.
- The app sends an encrypted
connectrequest; the bunker checks the one-time secret and answersack. - The app sends
get_public_keyto learn your pubkey, which can differ from the bunker’s own key in the link. - From then on, every
sign_eventrequest 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" });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…”. alice@alpha.example.
It works like a name badge that a company prints for you. Alice writes alice@alpha.example in her
profile (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.
Alice's pubkey: e550c6e92808f358…
Parse the address, build the well-known URL, fetch nostr.json from the domain, and compare the listed pubkey with the profile's pubkey.
-
Split at @; a bare domain means _@domain.
-
https://domain/.well-known/nostr.json?name=…
-
Fetch nostr.json (demo: from a pretend internet).
-
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 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.exampleis the “root” name and is displayed as justalpha.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
relaysmap 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 agreesQuick check
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.