Chapter 46 - Key Security
46
Nostr Implementation Possibility - Hex № 46

Nostr Remote Signing

A client asks a remote signer — the “bunker” — to sign events over relays, so the user's private key never touches the client.

1 Overview

A private key should touch as few systems as possible. Every app, operating system, and device that holds the key adds attack surface. This NIP moves the key out of the client. The key lives inside a remote signer. The remote signer can be a daemon, a server, or a dedicated hardware device.

The client and the remote signer talk in two directions over normal Nostr relays. Each request and each response is a kind 24133 event. The payload inside is encrypted with NIP-44. The client asks: sign this event, encrypt this text, decrypt this text. The remote signer answers. The user's key stays in the bunker.

The flow has five steps. First, the client generates a disposable client-keypair. Second, a connection is established; the remote signer learns the client-pubkey and the client learns the remote-signer-pubkey. Third, the client sends requests: it encrypts to the remote-signer-pubkey and adds a p tag with that pubkey. Fourth, the remote signer responds the same way toward the client-pubkey. Fifth, the client calls get_public_key to learn the user-pubkey.

Hint!

Kind 24133 sits in the ephemeral range (20000–29999) from NIP-01. Relays forward these events but do not store them. Both sides must be online, or use relays that briefly buffer.

Client client-keypair Relays Remote signer user-keypair (never leaves) kind 24133 - nip44(request) kind 24133 - nip44(response)
Fig. 46-1 — The bunker link. Encrypted requests go out, encrypted responses come back. The user's key stays put.

2 Roles and Keys

This NIP uses three keypairs. Keep them apart. All pubkeys in this NIP are in hex format.

Terminology
TermMeaning
userA person that is trying to use Nostr.
clientThe user-facing application. It sends requests to the remote signer.
remote-signerA daemon or server that answers requests from the client. Also called the “bunker”.
client-keypairKeys the client generates. Used to encrypt content and talk to the remote signer.
remote-signer-keypairKeys the remote signer uses to encrypt content and talk to the client. May equal the user-keypair, but not necessarily.
user-keypairThe keys that represent the user. They sign events for sign_event requests. The remote signer controls them.

The client-keypair is largely disposable. The client does not need to show it to the user. The client may store it locally. The client must delete it on logout. Because the remote-signer-pubkey and the user-pubkey can differ, the client must not assume they are equal. The client must call get_public_key after connect to learn the user-pubkey.

3 Initiating a Connection

There are two ways to connect. Either side can hand the other a token.

Flow A — the remote signer starts: bunker://

The remote signer provides a connection token in this form:

Connection token - signer side
bunker://<remote-signer-pubkey>?relay=<wss://relay-to-connect-on>
        &relay=<wss://another-relay-to-connect-on>
        &secret=<optional-secret-value>

The user passes this token to the client. The client then sends a connect request to the remote signer on the listed relays. A bunker:// token carries no information about the client. So in this flow the client should include client metadata in the connect request (see §4). The optional secret is valid for one successfully established connection only. The remote signer should ignore new connection attempts that reuse an old secret.

Flow B — the client starts: nostrconnect://

The client provides a token with nostrconnect:// as the protocol and the client-pubkey as the origin. Query parameters carry the rest:

nostrconnect:// query parameters
ParameterRequiredMeaning
relayrequiredOne or more relay URLs on which the client listens for responses from the remote signer.
secretrequiredA short random string. The remote signer must return it as the result of its response.
permsoptionalA comma-separated list of permissions the client asks the remote signer to approve.
nameoptionalThe name of the client application.
urloptionalThe canonical URL of the client application.
imageoptionalA small image that represents the client application.
Connection token - client side (example)
nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5
  ?relay=wss%3A%2F%2Frelay1.example.com
  &perms=nip44_encrypt%2Cnip44_decrypt%2Csign_event%3A13%2Csign_event%3A14%2Csign_event%3A1059
  &name=My+Client
  &secret=0s8j2djs
  &relay=wss%3A%2F%2Frelay2.example2.com

The user passes this token to the remote signer. The remote signer then sends a connect response event to the client-pubkey on the listed relays. The client discovers the remote-signer-pubkey from the author of that response. The secret must be provided to avoid connection spoofing. The client must validate the secret that the connect response returns.

Client Remote signer nostrconnect://… token (user hands it over) connect response: {"result":"0s8j2djs"} client checks secret, learns remote-signer-pubkey from author {"method":"get_public_key","params":[]} {"result":"<user-pubkey>"} {"method":"switch_relays","params":[]} {"result": relay list or null}
Fig. 46-2 — Client-initiated connection. The signer answers the token with the secret. The client then asks for the user pubkey and the signer's preferred relays.

4 Request Events — kind: 24133

A request is a normal NIP-01 event with kind 24133. Its author is the client-pubkey. It carries one p tag with the remote-signer-pubkey:

Wire format - request event
{
  "kind":    24133,
  "pubkey":  <client-pubkey>,
  "content": <nip44(<request>)>,
  "tags":    [["p", <remote-signer-pubkey>]]
}

The content field is a JSON-RPC-like message. It is encrypted with NIP-44. Its plaintext structure is:

Request payload (before encryption)
{
  "id":     <random_string>,
  "method": <method_name>,
  "params": [array_of_strings]
}
  • id is a random string that identifies the request. The response carries back the same ID.
  • method is the name of the method or command (see the table below).
  • params is a positional array of string parameters.

Methods

The client can send these ten methods to the remote signer:

Request methods (client → remote signer)
CommandParamsResult
connect[<remote-signer-pubkey>, <optional_secret>, <optional_requested_perms>, <optional_client_metadata>]"ack" or <required-secret-value>
sign_event[<{kind, content, tags, created_at}>]json_stringified(<signed_event>)
ping[]"pong"
get_public_key[]<user-pubkey>
nip04_encrypt[<third_party_pubkey>, <plaintext_to_encrypt>]<nip04_ciphertext> (NIP-04)
nip04_decrypt[<third_party_pubkey>, <nip04_ciphertext_to_decrypt>]<plaintext>
nip44_encrypt[<third_party_pubkey>, <plaintext_to_encrypt>]<nip44_ciphertext> (NIP-44)
nip44_decrypt[<third_party_pubkey>, <nip44_ciphertext_to_decrypt>]<plaintext>
switch_relays[]["<relay-url>", "<relay-url>", ...] or null
logout[]"ack"

Requested permissions

The connect method may carry optional_requested_perms for user convenience. The permissions are a comma-separated list of method[:params]. Example: nip44_encrypt,sign_event:4 asks for permission to call nip44_encrypt and to call sign_event with kind:4. For sign_event the optional parameter is the kind number. Parameters for the other methods are to be defined later. The perms parameter in a nostrconnect:// string may use the same format.

Tech Note

The source text says the permission format “may be used for the perms field of metadata” in the nostrconnect:// string. But perms is defined as a query parameter, and the metadata object lists only name, url, and image. The spec never resolves this wording. Treat perms as a query parameter.

Client metadata

The connect method may carry optional_client_metadata. This lets the remote signer identify and label the connection. It is a JSON-stringified object. Its fields mirror the nostrconnect:// query parameters, and all are optional: name, url, and image. This helps most in the bunker:// flow, where the remote signer has no other source for this information. To send metadata without requesting permissions, pass an empty string for optional_requested_perms. The metadata must then sit in the fourth position.

Warning!

Client metadata is client-supplied and unauthenticated. The remote signer should treat it as a display hint only. The remote signer must not use it for authorization decisions.

Switching relays

The remote signer stays in control of the relays used for the connection at all times. Its relay set can evolve as old relays shut down and new ones appear. In the client-initiated flow the client may even pick relays foreign to the signer's preferences. The signer must be able to switch those at once.

So compliant clients should send a switch_relays request immediately after the connection is established — always, or at reasonable intervals. The remote signer replies with its updated relay list, or null when nothing changes. As soon as the client receives an updated list, it updates its local state and sends all further requests on the new relays. The remote signer is then free to disconnect from the previous relays.

Ending a session

When the user logs out of the client, the client may send a logout request. This tells the remote signer that the session is no longer needed. On receipt, the remote signer should remove the session tied to the requesting client-pubkey and reject any further requests from it. A new connect is then required for a new session. The remote signer should reply with "ack" before it removes the session.

logout is a courtesy hint, not a security boundary. The remote signer must not rely on it being sent. The client must still delete its locally stored client-keypair on logout, whether or not the request was acknowledged.

5 Response Events — kind: 24133

A response is also a kind 24133 event. Its author is the remote-signer-pubkey. It carries one p tag with the client-pubkey:

Wire format - response event
{
  "id":         <id>,
  "kind":       24133,
  "pubkey":     <remote-signer-pubkey>,
  "content":    <nip44(<response>)>,
  "tags":       [["p", <client-pubkey>]],
  "created_at": <unix timestamp in seconds>
}

The content field is again a NIP-44 encrypted JSON-RPC-like message:

Response payload (before encryption)
{
  "id":     <request_id>,
  "result": <results_string>,
  "error":  <optional_error_string>
}
  • id is the ID of the request that this response answers.
  • result is a string with the result of the call. It can be a plain string or a JSON-stringified object.
  • error is optional. It is an error in string form. Its presence indicates an error with the request.

The remote signer must reply with an error to any request whose method is unknown or unsupported. Silent timeouts are not allowed for such requests.

Tech Note

Two rough edges in the source. First, the field is named result in the JSON but results in the prose; the wire field is result. Second, result is defined as a string, yet switch_relays returns an array or null; the spec does not say whether that value is JSON-stringified. Implementations should accept both forms.

6 Auth Challenges

An auth challenge is a special response. The remote signer sends it when it needs the user to authenticate by other means. The response content object takes this form:

Response payload - auth challenge
{
  "id":     <request_id>,
  "result": "auth_url",
  "error":  <URL_to_display_to_end_user>
}

The client should display the URL from the error field in a popup or a new tab. Then the client subscribes and listens for another response from the remote signer with the same request ID. The remote signer sends that final response once the user authenticates in the other window. If the user never authenticates, the final response never arrives.

Tech Note

The auth challenge overloads the error field: it carries the auth URL, not an error. This contradicts §5, where the presence of error “indicates an error with the request”. Clients must check for result == "auth_url" before they treat error as a failure.

Client Remote signer {"id":"a1","method":"sign_event",…} {"id":"a1","result":"auth_url","error":"https://…"} client opens the URL - user authenticates in a new window client keeps listening on the same request ID {"id":"a1","result": json_stringified(signed event)} If the user never authenticates, this message never arrives.
Fig. 46-3 — Auth challenge. The signer parks the request, points the user at a URL, and answers later with the same ID.

7 Announcing and Discovering Signers

A remote signer may publish its metadata with NIP-05 and NIP-89. With NIP-05, a request to <remote-signer>/.well-known/nostr.json?name=_ may return:

nostr.json - signer metadata
{
  "names": {
    "_": <remote-signer-app-pubkey>
  },
  "nip46": {
    "relays": ["wss://relay1", "wss://relay2", ...],
    "nostrconnect_url": "https://remote-signer-domain.example/<nostrconnect>"
  }
}
  • The <remote-signer-app-pubkey> may be used to verify the domain from the signer's NIP-89 event.
  • relays should be used to build a more precise nostrconnect:// string for this signer.
  • The nostrconnect_url template may redirect users into the signer's connection flow. Replace the <nostrconnect> placeholder with an actual nostrconnect:// string.

A remote signer may also publish a NIP-89 kind: 31990 event with a k tag of 24133. That event may include one or more relay tags and may include a nostrconnect_url tag. These tags carry the same meaning as the fields above.

A client may improve UX by discovering remote signers through their kind: 31990 events. The client may pre-generate nostrconnect:// strings for them. In that case the client should verify that the kind: 31990 event's author appears as the <remote-signer-app-pubkey> in the signer's nostr.json?name=_ file.

8 Worked Example

Three keys are in play. The remote-signer-pubkey is fa984bd7dbb2…8018f52. The user-pubkey is the same value in this example — the signer holds the user key directly. The client-pubkey is eff37350d839…531d86. The client asks the bunker to sign a kind-1 note.

Request event - sign_event
{
  "kind": 24133,
  "pubkey": "eff37350d839ce3707332348af4549a96051bd695d3223af4aabce4993531d86",
  "content": nip44({
    "id": <random_string>,
    "method": "sign_event",
    "params": [json_stringified(<{
      content: "Hello, I'm signing remotely",
      kind: 1,
      tags: [],
      created_at: 1714078911
    }>)]
  }),
  "tags": [["p", "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"]]
  // p-tags the remote-signer-pubkey
}

The remote signer fills in pubkey, id, and sig of the requested event, then answers:

Response event - signed result
{
  "kind": 24133,
  "pubkey": "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
  "content": nip44({
    "id": <random_string>,
    "result": json_stringified(<signed-event>)
  }),
  "tags": [["p", "eff37350d839ce3707332348af4549a96051bd695d3223af4aabce4993531d86"]]
  // p-tags the client-pubkey
}

A full session over the bunker's relay looks like this. Orange lines are decrypted client requests. Blue lines are decrypted signer responses.

Session transcript - decrypted payloads on wss://relay1.example.com
-- user pasted bunker://fa984bd7…?relay=…&secret=zx81 into the client --
→ {"id":"r1","method":"connect","params":["fa984bd7…","zx81","",
     "{\"name\":\"My Client\",\"url\":\"https://myclient.example\"}"]}
← {"id":"r1","result":"ack"}
→ {"id":"r2","method":"get_public_key","params":[]}
← {"id":"r2","result":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"}
→ {"id":"r3","method":"switch_relays","params":[]}
← {"id":"r3","result":null}
→ {"id":"r4","method":"sign_event","params":["{\"kind\":1,\"content\":\"Hello, I'm signing remotely\",…}"]}
← {"id":"r4","result":"{\"id\":\"…\",\"pubkey\":\"fa984bd7…\",\"sig\":\"…\",…}"}
-- an unsupported method gets an error, never silence --
→ {"id":"r5","method":"make_coffee","params":[]}
← {"id":"r5","result":"","error":"unsupported method"}
-- user logs out --
→ {"id":"r6","method":"logout","params":[]}
← {"id":"r6","result":"ack"}

9 Version History

NIP-46 began life as “Nostr Connect” and was rewritten twice. The current shape — bunker tokens, split signer and user keys, NIP-44 only — settled between 2024 and 2026. Notable changes, newest first:

  • 20262026-07Required an error reply for unknown or unsupported methods; no more silent timeouts.
  • 20262026-06Added optional client metadata to connect and the logout method for ending sessions.
  • 20262026-01Added switch_relays so the signer controls the relay set.
  • 20252025-02Removed the old get_relays method; relay tags joined the NIP-89 discovery event.
  • 20242024-12Dropped NIP-04 for the transport; request and response payloads use NIP-44 only.
  • 20242024-10Introduced the remote-signer-key as distinct from the user key; clients must call get_public_key after connect. NIP-05 login was removed and create_account moved to another NIP.
  • 20242024-02Full rewrite (“new version of NIP-46”); bunker:// tokens replaced the old npub1…#? notation.
  • 20232023-10fiatjaf rewrote the flow around request and response events; NIP-44 calls were added soon after.
  • Origin2023-02Marco Argentieri proposed “Nostr Connect”: connect your Nostr app with remote signing devices.

Original Proposal - Credits

AuthorMarco Argentieri
First commit2023-02-20 — “NIP-46: Nostr Connect 🔌 connect your Nostr app with remote signing devices” (PR #153)
Statusactive — widely implemented by signer apps and clients
Source46.md on GitHub - full change history

10 See Also

  • NIP-01 — the base event format, p tags, and the ephemeral kind range that 24133 lives in.
  • NIP-44 — the encryption that wraps every request and response payload.
  • NIP-04 — legacy encryption still exposed to apps via nip04_encrypt / nip04_decrypt.
  • NIP-05 — the nostr.json document a signer uses to announce its relays.
  • NIP-89 — kind: 31990 handler events used for signer discovery.
  • NIP-07 — the browser-extension signer; the local cousin of this remote flow.