Nostr Agent Onboarding · start here · index · source:
nostr-dev/docs/patterns/signer-login.md· snapshot 2026-10-10Paths such as
~/Documents/…,~/Production Environment/…,repos/…and services onlocalhostrefer 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/nipsmaster, 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 |
- The remote-signer key may equal the user key (Amber) but usually doesn't
(nsec.app, nsecbunker, nak with separate keys). Always call
get_public_keyafterconnectand use its result as the session pubkey. - Persist all three plus the relays. Example bug: nostrify 0.50.2
NUser.fromBunkerLoginrestores with the user pubkey as the signer pubkey. It works with Amber and times out with everything else.
2. Rules that apply to every method
- Verify every signed event you get back. Check that
verifyEvent(ev)is true, thatev.pubkey === sessionPubkey, and thatkind,content,tagsandcreated_atequal what you asked for. Recompute the id yourself. Extensions can switch accounts under you; bunkers can return anything. - 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, NDKsigner.timeout, and applesauce all wait forever. - Kind-scoped permissions. Request
sign_event:<kind>for each kind you will sign. Amber silently discards baresign_event(removeIf { kind == null && type == "sign_event" }), for both NIP-46 and NIP-55. - 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.
- 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_atis more than 5 minutes off its clock. - Never print the secret.
bunker://…&secret=and client keys have leaked into logs and error strings here several times, for example EarthTreeMediarip/*.logand nostrkitRedact. Redactsecret=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)
- Kind 24133 (ephemeral).
content= NIP-44 ciphertext of JSON. Requests are p-tagged with the remote-signer pubkey; responses are p-tagged with the client pubkey. - Request:
{"id": "<random>", "method": "<m>", "params": ["<string>", …]}. All params are strings. - Response:
{"id": "<same>", "result": "<string>", "error"?: "<string>"}. Iferroris present, the request failed.sign_event's result is a JSON string, not an object.
| 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) |
client_metadata_jsonis{"name":…,"url":…,"image":…}. It was added 2026-06-23. To send metadata without perms, pass""in the perms slot.- auth challenge: a response of
{"id", "result":"auth_url", "error":"<URL>"}means "open this URL". Keep listening for a second response with the same id, which is the real result.
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
- Both
relayandsecretare required. metadata=<json>is the old form (before 2024-11). Usename/url/image.- Amber URL-decodes the whole query and then splits on
&and=. Any value containing&,=,+or a space breaks even when percent-encoded. So keepnameto one word, makesecrethex, and leave outimageunless its URL is simple. - Use a hex secret from a CSPRNG (
crypto.getRandomValues), neverMath.random.
3.3 Checklist: the bunker:// flow (paste)
- Parse the URI: signer pubkey plus relays plus secret. Tolerate a missing secret.
- 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". - Connect to the relays and drop the dead ones. Relay health changes daily; see §3.6.
- Open the reply subscription first:
{"kinds":[24133], "#p":[client_pubkey], "since": now-60}with nolimit. - Don't usesince: now: Amber stamps replies about 5 s in the future. - Don't uselimit: 0alone: 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 stockfiatjaf.com/nostrand 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. - Always send
connect, even without a secret:[signerPk, secret||"", perms||"", metadataJSON]. Some signers refuse everything from a client that never connected. - Accept
"ack"or the secret as a successful connect result. - Call
get_public_key. That is the user. - 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. - Persist
{clientSecret, signerPubkey, userPubkey, relays}. Reconnect later withconnectwithout the secret (the secret was single-use). - On logout: send
logoutas a courtesy, then delete the client key.
3.4 Checklist: the nostrconnect:// flow (QR)
- Create the client key and persist it. Create a hex secret.
- 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.
- Show the QR, a copy button, and a plain
nostrconnect://link. On the same phone, tapping the link opens Amber directly. - Accept either of these as the pairing reply:
- a response whose
result === secret(what current signers send), or - a legacyconnectrequest whoseparams[1] === secret. - 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. - The author of the pairing reply is the remote-signer pubkey. Then continue with §3.3 steps 7–10.
3.5 Encryption dialect
- Send NIP-44 only. The spec dropped NIP-04 for the 24133 envelope on 2024-12-05.
- Decrypt with NIP-44 first, then fall back to NIP-04 (the ciphertext
contains
?iv=). Old signers still reply in NIP-04, and Amber still accepts both. - Never dual-send (the same request in both encryptions). Amber dedupes by event id and consumes the single-use secret on the first copy. The second copy then errors and no prompt appears.
- NIP-44 was extended on 2026-06-28 to allow plaintexts over 64 KiB, using a
6-byte length prefix. Old decoders, including paulmillr's reference
nip44, reject those. Largesign_eventpayloads can hit this.
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 |
- Default for new apps:
relay.nip46.com+bucket.coracle.social+nrs.primal.net+relay.damus.io. - Let the user edit the list, and probe it at login.
- Re-probe with the tester (§8) or with this, from a script file (nak stalls in
inline compound shell):
nak req -k 24133 -p <pk> --stream wss://Rplusnak event -k 24133 -p <pk> -c x --sec <fresh> wss://R. - Don't gate on relay OK for 24133. Some relays never OK ephemeral events. Start waiting for the reply immediately.
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:
- 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 withqueryIntentActivities(Intent(ACTION_VIEW, Uri.parse("nostrsigner:"))). Show "Sign in with Amber" only if something is installed. - Login = one
get_public_keyintent with no package set, plus apermissionsextra 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. - Storeresult(the pubkey) andpackage. Accept both npub and hex. - Don't callget_public_keyagain while logged in. - Later requests:
- First try the ContentResolver:
content://<package>.SIGN_EVENT, withselectionArgs = [payload, pubkey_hex, current_user_hex]. - Anullcursor or empty row means manual approval is needed, so fall back to the intent withsetPackage(package). - Arejectedcolumn means the user chose "always reject". Stop; don't fall back. - UsegetColumnIndexsafely: a missing column must not crash. - Read
result, notsignature. Amber sets both; other signers may not.eventis also returned forsign_event. - Rejection is
RESULT_OK+rejected=true(spec rewrite 2026-06-03).resultCode != RESULT_OKmeans the signer failed. Code that only checksresultCodeturns a rejection into "success with an empty signature" (the OTSuite bug). - Match results by
id. Queue requests: one Amber prompt at a time (OTSuiteSignerIntentBridgemutex). Deliver a cancel to the waiting caller; don't let it time out silently. - 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>
- Callback must be fragment-based:
callbackUrl=https://host/path#nip55=. - Amber splits the decoded data on?, so a?x=callback gets mangled (Idle StarFighterd98801c). - Amber openscallbackUrl + Uri.encode(result)in a new VIEW intent. - Listen forhashchangeas well as page load, because a fragment-only navigation doesn't reload the page. - Pending state in
localStoragewith an expiry (notsessionStorage). The result often lands in a new tab. - Request
returnType=signature. Rebuild the event from the exact stored unsigned event, including itscreated_at, recompute the id, then verify. - Rebuilding with a newcreated_atbreaks the signature. This is the Idle StarFighter bug. - Forsign_event, includepubkeyin the unsigned JSON. - Encode the payload withencodeURIComponent. An unescaped?inside the JSON breaks Amber's parser. - With no
callbackUrl, Amber copies the result to the clipboard and shows a toast. Offer a "paste result" box as the fallback. - Chrome (reported, not independently confirmed).
- Since about August 2026, Chrome reportedly no longer sends
EXTRA_APPLICATION_ID, so Amber treats a barenostrsigner:link as an app intent and fails with "malformed nostrsigner request". - Amber's app-intent branch readstype,callbackUrl,returnType,pubkeyandidfrom extras (verified in Amber source,IntentUtils.getIntentDataFromIntent). So an Androidintent:URL works as a fallback:intent:<payload>#Intent;scheme=nostrsigner;S.type=sign_event;S.returnType=signature;S.callbackUrl=<enc>;end. - Offer both links. - A
nostrsigner:link with no signer installed does nothing, and this can't be detected. Say so in the UI. - 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
- Injection is late. Extensions inject after your script runs (nos2x adds
an async
<script>). Don't decide "no extension" at render time. Pollwindow.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. - Feature-detect
nip44before relying on it. NDK's check is buggy (an empty array is truthy). - Serialize encrypt and decrypt calls. Extensions throw "call already executing" on parallel prompts (NDK queues and retries).
- Verify per §2.1. The user can switch accounts in the extension
mid-session, so compare
signed.pubkeywith the session. getRelays()was removed from the spec (2025-02). Don't depend on it.- Workers and wasm workers don't get
window.nostr. Bridge the call from the page. - Some extensions reject an unsigned event whose
pubkeyis the zero key. Omitpubkeyor set it to the real one.
7. Definition of done for a login feature
- [ ] Each offered method logs in, signs a test event that you verify (§2.1), and survives a page reload or app restart without a new prompt (session restored from persisted state).
- [ ] NIP-46 tested against
nak bunker(fast, hermetic) and real Amber. nak speaks only NIP-44 and has no kind-scoped permissions, so a nak-only test proves neither the NIP-04 fallback nor the permissions (Nostr-Agenda's unscopedsign_eventpassed with nak). - [ ] Both NIP-46 directions tested: pasted
bunker://and scanned QR. - [ ] Killing one relay in the list doesn't hang login.
- [ ] Rejecting on the phone shows "rejected", not a hang or fake success.
- [ ] Timeouts show a message that names the likely cause (Amber offline, notifications off, wrong relay).
- [ ] Logout deletes the client key; logging in again prompts again.
- [ ] No secret or nsec appears in any log, error, URL or analytics.
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.