Markdown source for agents: https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/signer-login.md

Nostr Agent Onboarding · start here · index · source: nostr-dev/docs/patterns/signer-login.md · snapshot 2026-10-10

Paths such as ~/Documents/…, ~/Production Environment/…, repos/… and services on localhost refer to the author's workstation and are not available to you — read them as worked examples of a setup you can recreate.

Pattern: signer login — NIP-07, NIP-46 (bunker / nostrconnect), NIP-55 (Amber)

Read this before writing any login code. Every app built on this machine got signer login working only after several broken attempts (Facebook-Museum, Testimony, Kubo, In-Your-Face, GRASPCONTROL, Idle StarFighter, Nostr-Agenda, OTSuite …). The failures were almost always the same dozen mistakes. They are collected here with the fix, so the next app gets it right in one go.

Verified against the NIP texts on nostr-protocol/nips master, Amber source, and the library sources on 2026-09-19. Spec dates are in §9. If you are reading this much later, re-check §9 first — NIP-46 and NIP-55 both changed in 2026.

Test with the Signer Login Tester (§8, https://npub138kdqjl645xz6rvuw92m2v4us0shhpxezgqqz9zrrj2jdstakvus86qzyh.nsite.lol/): a static page that exercises every method below and logs every wire message. If your signer works there and not in your app, the bug is in your app.


0. Which methods to offer

Platform Offer Notes
Web app / nsite NIP-07 + NIP-46 paste bunker:// + NIP-46 QR nostrconnect:// NIP-55-over-web is optional and fragile (§5). The NIP-55 spec itself says web apps should prefer NIP-46.
Android native NIP-55 (Amber etc.) + NIP-46 Use Quartz's nip55AndroidSigner, don't hand-roll (§4).
Go desktop / daemon / CLI NIP-46 Use the patched library from Testimony, not stock fiatjaf.com/nostr (§3.7).
Go→wasm in the page NIP-07 via a JS bridge + NIP-46 Extensions don't inject window.nostr into workers — bridge from the page (Facebook-Museum cmd/wasm/extsigner.go).

Web clients must never ask for an nsec (Nostr-exp core/03-how-to-develop.md). Local-key onboarding is acceptable only in native introductory apps.


1. The three keys (the #1 conceptual bug)

NIP-46 has three keypairs. Mixing them up causes most "logged in as the wrong person" and "can't reconnect" bugs:

Key Who holds it Where you see it
client key your app, generated once, persisted host part of nostrconnect://<client-pubkey>
remote-signer key the signer host part of bunker://<remote-signer-pubkey>; author of every response
user key the signer only via get_public_key

2. Rules that apply to every method

  1. Verify every signed event you get back. Check that verifyEvent(ev) is true, that ev.pubkey === sessionPubkey, and that kind, content, tags and created_at equal what you asked for. Recompute the id yourself. Extensions can switch accounts under you; bunkers can return anything.
  2. Every request has a timeout, and every timeout is visible in the UI. Humans approve on phones, so use about 120 s for connect and QR pairing and about 60 s per sign. Libraries default to no timeout: nostr-tools sendRequest, NDK signer.timeout, and applesauce all wait forever.
  3. Kind-scoped permissions. Request sign_event:<kind> for each kind you will sign. Amber silently discards bare sign_event (removeIf { kind == null && type == "sign_event" }), for both NIP-46 and NIP-55.
  4. Log the wire. Keep a debug view of each request, response, relay OK and error. Every multi-day debugging session here ended once someone looked at the raw 24133 traffic.
  5. Say the Amber caveats in the UI: - Amber drops all incoming NIP-46 requests when its Android notification permission is off (the Android 13+ default). - Since Amber 6.5.0 it silently rejects requests whose created_at is more than 5 minutes off its clock.
  6. Never print the secret. bunker://…&secret= and client keys have leaked into logs and error strings here several times, for example EarthTreeMedia rip/*.log and nostrkit Redact. Redact secret= before logging a URI, and don't wrap raw user input into errors with %w.

3. NIP-46 — remote signing (bunker and nostrconnect)

3.1 Wire format (current)

method params result
connect [remote_signer_pubkey, secret_or_"", perms_or_"", client_metadata_json] "ack" or the secret
get_public_key [] user pubkey, hex
sign_event [json({kind, content, tags, created_at})] json(signed event)
nip44_encrypt / nip44_decrypt / nip04_encrypt / nip04_decrypt [third_party_pubkey, text] text
ping [] "pong"
switch_relays [] JSON string ["wss://…"] or null
logout [] "ack" (courtesy; you must still delete the client key)

3.2 URIs

bunker:// (the signer creates it; the user pastes it into your app):

bunker://<remote-signer-pubkey>?relay=wss://a&relay=wss://b&secret=<single-use>

nostrconnect:// (your app creates it; shown as a QR, or as a link on the same phone):

nostrconnect://<client-pubkey>?relay=wss%3A%2F%2Fa&relay=wss%3A%2F%2Fb&secret=<hex>&perms=sign_event%3A1%2Cnip44_encrypt&name=MyApp&url=https%3A%2F%2Fmyapp.example

3.3 Checklist: the bunker:// flow (paste)

  1. Parse the URI: signer pubkey plus relays plus secret. Tolerate a missing secret.
  2. Load the persisted client key, or create one and save it now, before the first connect. - Amber auto-acks a repeat connect from a client it already knows. - A fresh key per attempt looks like a stranger reusing a consumed single-use secret, and no prompt ever appears. This is the #1 cause of "Amber doesn't work".
  3. Connect to the relays and drop the dead ones. Relay health changes daily; see §3.6.
  4. Open the reply subscription first: {"kinds":[24133], "#p":[client_pubkey], "since": now-60} with no limit. - Don't use since: now: Amber stamps replies about 5 s in the future. - Don't use limit: 0 alone: that relies on relays live-streaming correctly. - Don't wait for EOSE from all relays before sending. One dead relay then hangs login forever; that is a stock fiatjaf.com/nostr and NDK bug. Wait at most about 2 s for the first EOSE, then send. - Don't send before the REQ is live either. Under wasm, the request beat the REQ on a shared socket and the ephemeral reply was never replayed.
  5. Always send connect, even without a secret: [signerPk, secret||"", perms||"", metadataJSON]. Some signers refuse everything from a client that never connected.
  6. Accept "ack" or the secret as a successful connect result.
  7. Call get_public_key. That is the user.
  8. Send switch_relays. If it returns a list, move the session to those relays (resubscribe first, then drop the old ones). This has been a spec SHOULD since 2026-01-26. Amber 6.3+ changed its default relays, so this matters.
  9. Persist {clientSecret, signerPubkey, userPubkey, relays}. Reconnect later with connect without the secret (the secret was single-use).
  10. On logout: send logout as a courtesy, then delete the client key.

3.4 Checklist: the nostrconnect:// flow (QR)

  1. Create the client key and persist it. Create a hex secret.
  2. Choose relays the signer is likely to use (§3.6). Open and arm the subscription before showing the QR. A fast phone replies within a second; if you're not listening yet, the reply is lost, because 24133 is ephemeral.
  3. Show the QR, a copy button, and a plain nostrconnect:// link. On the same phone, tapping the link opens Amber directly.
  4. Accept either of these as the pairing reply: - a response whose result === secret (what current signers send), or - a legacy connect request whose params[1] === secret.
  5. Never accept a bare "ack" in this flow. The client pubkey is printed in the QR, so anyone can reply "ack" and become "your signer". The spec says the client MUST validate the secret. nostr-tools enforces this since 2026-08-19. applesauce, rust-nostr, nostr-login and Archon-Web still accept "ack", so don't copy them.
  6. The author of the pairing reply is the remote-signer pubkey. Then continue with §3.3 steps 7–10.

3.5 Encryption dialect

3.6 Relays for NIP-46 (probed 2026-09-19, fresh keys, publish and receive)

Relay 24133 relayed? Note
wss://relay.nip46.com ✅ dedicated NIP-46 relay; in Amber 6.6 defaults
wss://bucket.coracle.social ✅ in Amber 6.6 defaults
wss://nrs.primal.net ✅ in Amber 6.6 defaults
wss://relay.damus.io ✅ removed from Amber defaults in 6.3; intermittent 503s
wss://relay.primal.net, wss://nostr.oxtr.dev ✅
wss://auth.nostr1.com ⚠️ AUTH required in Amber defaults; you must answer NIP-42 AUTH (sign kind 22242 with the client key) or publishes fail with auth-required
wss://relay.nsec.app ❌ unreachable still the default in many libraries and in apps on this machine; don't rely on it
wss://relay.nos.social ❌ blocked: kind not allowed
wss://nos.lol ❌ timed out that day
local ngit-relay ws://localhost:8081, nostr-rs-relay ❌ / never OKs don't use for NIP-46 tests; use a public relay or an in-process khatru

3.7 Library status (2026-09-19): what to use and what to patch

Library Verdict Bugs you must work around
nostr-tools BunkerSigner (≥2.25) best JS base No per-request timeout (wrap it). A reply with an empty result never resolves. switch_relays compares a sorted list against an unsorted one. queryBunkerProfile reads the old NIP-05 nip46 shape. Pass a persisted clientSecretKey.
applesauce-signers NostrConnectSigner good (grimoire uses it) Accepts bare "ack" in nostrconnect. switchRelays checks Array.isArray on a string, so it never switches. No timeout. onAuth defaults to window.open outside a click, which popup blockers stop.
NDK NDKNip46Signer (3.0.x) avoid for NIP-46 Sends connect with [userPubkey??"", secret] (the wrong first param). Only "ack" counts as success. Waits for EOSE before sending. Sticks to NIP-04 after seeing one NIP-04 reply. The nostrconnect URI has a single relay and a Math.random secret. No timeout by default.
nostrify (MKStack) ok for NIP-07; bunker restore buggy Bug in §1. No auth_url handling.
fiatjaf.com/nostr nip46 (Go, stock) don't use stock Waits for EOSE from all relays (a dead relay hangs forever). Drops NIP-04 replies. 30 s hard-coded sign timeout (BunkerSignTimeout is ignored). One context bounds both the connect and the listener (the second sign fails with "context canceled"). Broken 1-in-10 pre-connect switch_relays. Waits for the relay OK before reading the reply. Use the patched copy in Production Environment/Testimony/third_party/nostr/ (see its FORK.md) plus internal/signer/.
rust-nostr nostr-connect ok Accepts "ack" in nostrconnect. No switch_relays/logout.
nostr-login unmaintained since 2025-03 Don't adopt.

Go lifetime rule. The bunker client's subscription lives as long as the context you created it with. Build it with an app-lifetime context and use per-request context.WithTimeout only around individual calls. In-Your-Face-try2 and icdib both defer cancel() the login context, so the session dies the moment login returns.


4. NIP-55 — Android native (Amber)

Use Quartz (com.vitorpamplona.quartz, nip55AndroidSigner), as Nostr-Agenda does. It already does content-resolver-first with intent fallback, id matching, rejection handling, signature verification and timeouts. If you must hand-roll, implement all of this:

  1. Manifest <queries>, or Android 11+ can't see the signer (neither the intent nor the ContentResolver): xml <queries><intent><action android:name="android.intent.action.VIEW"/> <category android:name="android.intent.category.BROWSABLE"/> <data android:scheme="nostrsigner"/></intent></queries> Detect with queryIntentActivities(Intent(ACTION_VIEW, Uri.parse("nostrsigner:"))). Show "Sign in with Amber" only if something is installed.
  2. Login = one get_public_key intent with no package set, plus a permissions extra that lists every kind you'll sign: [{"type":"sign_event","kind":31922},{"type":"nip44_decrypt"}]. - Quartz's default covers only kind 22242, which means a prompt on every sign. - Store result (the pubkey) and package. Accept both npub and hex. - Don't call get_public_key again while logged in.
  3. Later requests: - First try the ContentResolver: content://<package>.SIGN_EVENT, with selectionArgs = [payload, pubkey_hex, current_user_hex]. - A null cursor or empty row means manual approval is needed, so fall back to the intent with setPackage(package). - A rejected column means the user chose "always reject". Stop; don't fall back. - Use getColumnIndex safely: a missing column must not crash.
  4. Read result, not signature. Amber sets both; other signers may not. event is also returned for sign_event.
  5. Rejection is RESULT_OK + rejected=true (spec rewrite 2026-06-03). resultCode != RESULT_OK means the signer failed. Code that only checks resultCode turns a rejection into "success with an empty signature" (the OTSuite bug).
  6. Match results by id. Queue requests: one Amber prompt at a time (OTSuite SignerIntentBridge mutex). Deliver a cancel to the waiting caller; don't let it time out silently.
  7. Compose pitfall. Activity results arrive while the screen is STARTED, not RESUMED, so navigating directly from the callback is silently dropped. Set a flag and navigate in a LaunchedEffect.

5. NIP-55 over the web (nostrsigner: URLs), optional and fragile

The spec recommends NIP-46 for web apps. If you still offer it (it's the only no-relay option for a phone browser):

nostrsigner:<urlencoded payload>?compressionType=none&returnType=signature&type=<method>&callbackUrl=<url>
  1. Callback must be fragment-based: callbackUrl=https://host/path#nip55=. - Amber splits the decoded data on ?, so a ?x= callback gets mangled (Idle StarFighter d98801c). - Amber opens callbackUrl + Uri.encode(result) in a new VIEW intent. - Listen for hashchange as well as page load, because a fragment-only navigation doesn't reload the page.
  2. Pending state in localStorage with an expiry (not sessionStorage). The result often lands in a new tab.
  3. Request returnType=signature. Rebuild the event from the exact stored unsigned event, including its created_at, recompute the id, then verify. - Rebuilding with a new created_at breaks the signature. This is the Idle StarFighter bug. - For sign_event, include pubkey in the unsigned JSON. - Encode the payload with encodeURIComponent. An unescaped ? inside the JSON breaks Amber's parser.
  4. With no callbackUrl, Amber copies the result to the clipboard and shows a toast. Offer a "paste result" box as the fallback.
  5. Chrome (reported, not independently confirmed). - Since about August 2026, Chrome reportedly no longer sends EXTRA_APPLICATION_ID, so Amber treats a bare nostrsigner: link as an app intent and fails with "malformed nostrsigner request". - Amber's app-intent branch reads type, callbackUrl, returnType, pubkey and id from extras (verified in Amber source, IntentUtils.getIntentDataFromIntent). So an Android intent: URL works as a fallback: intent:<payload>#Intent;scheme=nostrsigner;S.type=sign_event;S.returnType=signature;S.callbackUrl=<enc>;end. - Offer both links.
  6. A nostrsigner: link with no signer installed does nothing, and this can't be detected. Say so in the UI.
  7. When an installed PWA opens the callback, it opens in a browser tab, not the PWA window.

6. NIP-07 — browser extension

await window.nostr.getPublicKey()            // hex
await window.nostr.signEvent({created_at, kind, tags, content})  // → full event
window.nostr.nip44?.encrypt/decrypt(pk, text) // optional — feature-detect
window.nostr.nip04?.encrypt/decrypt(pk, text) // deprecated
  1. Injection is late. Extensions inject after your script runs (nos2x adds an async <script>). Don't decide "no extension" at render time. Poll window.nostr (truthy, not 'nostr' in window) every 100 ms for about 1–2 s, and re-check when the button is clicked. nostr-archive checks at click time and never had the bug; nostr-archive-app, ETM and grimoire check at render and show "install an extension" wrongly.
  2. Feature-detect nip44 before relying on it. NDK's check is buggy (an empty array is truthy).
  3. Serialize encrypt and decrypt calls. Extensions throw "call already executing" on parallel prompts (NDK queues and retries).
  4. Verify per §2.1. The user can switch accounts in the extension mid-session, so compare signed.pubkey with the session.
  5. getRelays() was removed from the spec (2025-02). Don't depend on it.
  6. Workers and wasm workers don't get window.nostr. Bridge the call from the page.
  7. Some extensions reject an unsigned event whose pubkey is the zero key. Omit pubkey or set it to the real one.

7. Definition of done for a login feature

nak recipes (run from a script file; add </dev/null in loops):

# a bunker that signs for key K, on two relays, prints bunker:// URI (+ QR)
nak bunker --sec <hex-or-nsec> --qrcode wss://relay.nip46.com wss://bucket.coracle.social
# the other direction (app shows a nostrconnect:// QR): `connect` only hands the URI to
# an ALREADY RUNNING `nak bunker` over a unix socket — start the bunker first:
nak bunker --sec <hex> wss://relay.nip46.com &      # terminal 1
nak bunker connect 'nostrconnect://…'               # terminal 2 (fails "connection refused" if no bunker runs)
# drive a bunker as a client with a persisted client key
nak event -k 1 -c test --sec 'bunker://…' --connect-as <client-hex> wss://relay.damus.io

8. Reference implementations on this machine

Need Copy from Why
Test any signer Signer Login Tester, live at https://npub138kdqjl645xz6rvuw92m2v4us0shhpxezgqqz9zrrj2jdstakvus86qzyh.nsite.lol/; source at ~/Production Environment/Signer-Login-Tester/ (tools/e2e.mjs = 19 automated checks with nak bunker) One static page. All methods, full wire log, written to this document.
NIP-46 in JS, hand-rolled the tester's src/nip46.js + src/relay.js (about 250 lines total) Implements §3 exactly, including NIP-42 AUTH for auth.nostr1.com
NIP-46 in JS, library grimoire src/components/nostr/LoginDialog.tsx + applesauce Opens the subscription before the QR, supports abort, persists accounts. Add timeouts and the secret-only check.
NIP-46 in Go ~/Production Environment/Testimony/internal/signer/ + third_party/nostr/ Patched lib; verified against real Amber 2026-08-22. The reasoning is in Facebook-Museum/app/third_party/nostr/FORK.md Deltas 2–4. Don't copy Facebook-Museum's own ConnectPerms, which are bare (known-issues §6).
NIP-46 Go daemon lifetime Sessions/GRASPCONTROL/internal/identity/manager.go App-lifetime context, persisted session.json
NIP-55 Android ~/Production Environment/Nostr-Agenda (Quartz 1.11) Verified with real Amber 6.2.2
NIP-55 web Idle StarFighter/web/nip55.js + docs/NOSTR.md §NIP-55 Fragment callback, localStorage pending state. But fix its created_at rebuild bug (§5.3).
NIP-07 NDK NDKNip07Signer pattern: poll, queue, check at click time
Signer side (what Amber really does) Sessions/bunker-android/awareness/amber-upstream/ (IntentUtils.kt, SignerProvider.kt, NostrConnectUtils.kt) Read the source when the spec and behaviour disagree

Known-bad code on this machine (don't copy): see known-issues.md §6.


9. Spec changelog you must know (dates = commit on nips master)

Date Change
2024-10-29 NIP-46: remote-signer pubkey ≠ user pubkey
2024-11-12 NIP-46: get_public_key required after connect; nostrconnect secret required; NIP-05 login and create_account removed
2024-11-22 nostrconnect metadata= → name/url/image/perms params
2024-12-05 NIP-46 envelope NIP-44 only
2025-02-14 get_relays / getRelays removed from NIP-07/46/55
2025-10-13 NIP-55: store the pubkey, don't re-call get_public_key; resolver arg = hex pubkey
2025-12-18 NIP-55: content-resolver GET_PUBLIC_KEY removed (intent only)
2026-01-26 NIP-46: switch_relays added (client SHOULD call after connect)
2026-06-03 NIP-55 rewritten: rejection = RESULT_OK+rejected; selectionArgs order [payload, pubkey, current_user]
2026-06-14 NIP-46: logout
2026-06-23 NIP-46: 4th connect param optional_client_metadata
2026-06-28 NIP-44: plaintexts > 64 KiB allowed (6-byte length prefix)
2026-07-15 NIP-46: unknown methods MUST get an error reply
Amber 6.3.0 default relays drop relay.damus.io
Amber 6.5.0 (2026-08-14) rejects 24133 older or newer than ±5 min; tracks consumed ids

Primary sources: https://github.com/nostr-protocol/nips/blob/master/07.md, 46.md, 55.md, https://github.com/greenart7c3/Amber/releases. Refresh this table when you touch login code: https://github.com/nostr-protocol/nips/commits/master/46.md.atom.