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

Android Signer Application

A dedicated Android app holds the private key. Clients on the same device ask it to sign, encrypt, and decrypt — and never see the key.

1 Overview

Every Nostr action needs a signature. If every client app stores the private key, one bad app leaks it. This NIP moves the key into one trusted app: the signer.

The signer is an Android application. It holds the user's private key and answers requests from a client. The client is a user-facing application that sends signing requests. It may be another Android application or a web page. The two apps run on the same device and talk over standard Android mechanisms. The client never handles the private key.

Communication is 2-way. The client sends a request with a method name and a payload. The signer asks the user for approval, or answers silently if the user pre-approved the method. Then it returns a result to the client.

Hint!

This chapter is the local, same-device cousin of NIP-46 remote signing and the NIP-07 browser extension. Same idea, different transport: Android Intents instead of relays or window.nostr.

Android Client Web Page Signer holds the key Intent (user approves) Content Resolver (background) nostrsigner: URL callbackUrl / clipboard
Fig. 55-1 — Three transports, one signer. Android clients use Intents or the Content Resolver. Web pages use a nostrsigner: URL and get the result back by callback or clipboard.

2 Roles and Terms

The spec uses five terms. Use them exactly as defined here. All public keys in this NIP are hex strings.

Terminology
TermMeaning
userA person trying to use Nostr.
clientA user-facing application that sends signing requests.
signerAn Android application that holds the user's private key and answers requests from the client.
user-pubkeyThe public key that represents the user. The signer returns it from get_public_key.
package nameThe Android package name of the signer, for example com.example.signer. The client uses it to address all later requests.

A client can talk to the signer in three ways:

  • Intents — for Android clients. The signer opens. The user approves or rejects the request manually. The result returns to the client.
  • Content Resolver — for Android clients. The request runs in the background and the signer does not open. This works only for permissions the user chose to remember.
  • Web — for web clients. The signer returns the result through a callbackUrl, or copies it to the clipboard.

3 Setup and Connection

An Android client must declare the nostrsigner scheme in its AndroidManifest.xml. Without this declaration the client cannot detect or call the signer.

AndroidManifest.xml - required queries block
<queries>
  <intent>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="nostrsigner" />
  </intent>
</queries>

The client can then check for an installed signer. Query the package manager for activities that handle a nostrsigner: view intent:

Kotlin - signer detection
fun isExternalSignerInstalled(context: Context): Boolean {
  val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:"))
  return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
}

Initiating a connection

The connection handshake has four steps:

  1. The client sends a get_public_key request.
  2. The signer responds with the user-pubkey and its package name.
  3. The client stores both. It SHOULD NOT call get_public_key again while the user stays logged in.
  4. The client addresses all further requests to that package name.

The client MAY pass a list of permissions with get_public_key. The user can then pre-authorize methods in one step. The Content Resolver uses these remembered permissions to answer in the background. Each permission is an object with a type (a method name) and, for sign_event, an optional kind:

Permissions list - example
[
  { "type": "sign_event", "kind": 22242 },
  { "type": "nip44_decrypt" }
]

4 Methods

Every request has a type (the method name) and a payload. Each transport passes the payload differently, but the meaning is shared. Seven methods exist:

Signer methods
MethodPayloadExtra paramsResult
get_public_key(empty)permissionsthe user-pubkey
sign_eventevent JSONcurrent_userthe signature, and the signed event
nip04_encryptplaintextpubkey, current_userthe ciphertext
nip44_encryptplaintextpubkey, current_userthe ciphertext
nip04_decryptciphertextpubkey, current_userthe plaintext
nip44_decryptciphertextpubkey, current_userthe plaintext
decrypt_zap_eventevent JSONcurrent_userthe decrypted event JSON

Three shared parameters appear across methods:

  • current_user — the user-pubkey currently logged in to the client, in hex format.
  • pubkey — the public key of the other party in encryption or decryption, in hex format.
  • id — an optional client-chosen string. The signer echoes it back in the result. Use it to match responses when you send several requests without waiting.

The encryption and decryption methods implement NIP-04 and NIP-44. The decrypt_zap_event method decrypts a private zap (NIP-57).

5 Intent Flow

In the Intent flow, the payload is the data of a nostrsigner: URI. All other parameters travel as intent extras. The client launches the intent and receives the result through registerForActivityResult or rememberLauncherForActivityResult.

Set the intent's package to the stored signer package name. Omit it only for get_public_key, before the client knows the package name.

Kotlin - sending a request
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$payload")).apply {
  `package` = signerPackageName        // omit only for get_public_key
  putExtra("type", "sign_event")
  putExtra("id", event.id)             // optional, echoed back
  putExtra("current_user", userPubkey)
}
launcher.launch(intent)

The signer answers through the returned intent's extras:

Result extras
ExtraDescription
resultThe method result: pubkey, signature, ciphertext, plaintext, or event JSON.
idThe id sent in the request, echoed back.
eventThe signed event JSON. Present for sign_event only.
packageThe signer package name. Present for get_public_key only.
rejectedtrue when the user rejected the request.
Warning!

Do not read the resultCode as a rejection. A resultCode other than Activity.RESULT_OK means the signer failed — for example, it crashed. When the user rejects, the signer returns RESULT_OK with the rejected extra set to true. Check both.

Kotlin - reading the result
onResult = { result ->
  if (result.resultCode != Activity.RESULT_OK) {
    // signer error (e.g. crash)
  } else if (result.data?.getBooleanExtra("rejected", false) == true) {
    // user rejected the request
  } else {
    val value = result.data?.getStringExtra("result")
  }
}
Client Signer launch("nostrsigner:<payload>", type, id, current_user) signer opens - user approves or rejects RESULT_OK - extras: result, id, event RESULT_OK - extras: rejected=true Any other resultCode = signer failure, not a rejection.
Fig. 55-2 — Intent round trip. One request, one approval screen, one result intent. Rejection is a normal RESULT_OK answer.

Signing multiple events in one launch

To sign several events without reopening the signer for each one, add two flags to the intent. The signer then returns one array of results in a results extra. The signer must declare android:launchMode="singleTop" for this to work.

Kotlin - batch signing
// client adds:
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)

// signer answers with:
intent.putExtra("results",
  listOf(Result(package = signerPackageName, result = signature, id = intentId)).toJson())

6 Content Resolver Flow

The Content Resolver flow runs in the background. The signer does not open. It answers only for permissions the user chose to remember. The client queries a content URI built from the signer package name and the method name in uppercase:

Content URI shape
content://<package-name>.<TYPE>       // e.g. SIGN_EVENT, NIP44_ENCRYPT

The query's selectionArgs carry the payload and the parameters. The order is fixed: [payload, pubkey, current_user]. Pass an empty string for a parameter the method does not use.

Kotlin - background query
val result = context.contentResolver.query(
  Uri.parse("content://com.example.signer.SIGN_EVENT"),
  listOf(eventJson, "", loggedInUserPubkey),
  null, null, null,
)

The cursor returns a result column. For sign_event it also returns an event column with the signed event JSON. The query fails — it returns null, or the cursor has a rejected column — when any of these is true:

  • The user did not enable "remember my choice" for this request.
  • The user-pubkey is not present in the signer.
  • The request type is not recognized.
  • The user chose to always reject this request. The signer returns a rejected column. The client SHOULD NOT then fall back to an Intent.
Kotlin - reading the cursor
if (result == null) return
if (result.getColumnIndex("rejected") > -1) return
if (result.moveToFirst()) {
  val value = result.getString(result.getColumnIndex("result"))
  // for sign_event also: result.getColumnIndex("event")
}

Clients SHOULD store the user-pubkey locally. Do not call get_public_key again after the user is logged in.

Query outcome map result row Permission remembered — read result (and event for sign_event) null No remembered choice, unknown user-pubkey, or unknown type rejected column User always rejects — stop; do not fall back to an Intent
Fig. 55-3 — Background query outcomes. Only a remembered approval produces a row with a result column.
Tech Note

The source spec lists the four failure conditions under one sentence: "the query returns null, or a rejected column is present". It states the rejected column only for the always-reject case. For the first three conditions it does not say which of the two failure shapes applies. Handle both shapes for every condition, as the sample code above does.

7 Web Flow

A web page cannot receive an intent result. Instead, the signer appends the result to a callbackUrl. If the request has no callbackUrl, the signer copies the result to the clipboard. The request is a nostrsigner: URL. The path is the payload. The query string carries the parameters:

Web request shape
nostrsigner:<payload>?type=<method>&pubkey=<hex_pubkey>&callbackUrl=https://example.com/?result=
Web query parameters
ParameterDescription
typeThe method name.
pubkeyThe other party's hex pubkey. Used by the encryption and decryption methods.
callbackUrlThe URL the signer appends the result to. If omitted, the signer copies the result to the clipboard.
returnTypesignature or event.
compressionTypenone (default) or gzip. With gzip, the returned event is the string "Signer1" plus Base64(gzip(event JSON)). Useful because intents and URLs have length limits.
JavaScript - opening the signer
window.href = `nostrsigner:${eventJson}?compressionType=none&returnType=signature` +
              `&type=sign_event&callbackUrl=https://example.com/?event=`;
Warning!

Consider NIP-46 remote signing for web applications instead. The web flow here cannot call the signer in the background. The user sees a signer popup for every single request.

Tech Note

The source spec's JavaScript samples assign window.href. That property does not trigger navigation; the working form is window.location.href. The samples are reproduced as written. Adjust in real code.

8 Worked Example

A web page asks the signer for one signature. It sends a kind-1 event, then reads the result from its own query string when the signer calls back.

web-client.html - full round trip
<script>
window.onload = function() {
  // 2. when the signer calls back, the result is in the URL
  var params = new URL(window.location.href).searchParams;
  var result = params.get("event");
  if (result) alert(result);

  // 1. build the request link
  var json = { kind: 1, content: "test" };
  var encodedJson = encodeURIComponent(JSON.stringify(json));
  var anchor = document.createElement("a");
  anchor.href = `nostrsigner:${encodedJson}?compressionType=none` +
                `&returnType=signature&type=sign_event` +
                `&callbackUrl=https://example.com/?event=`;
  anchor.textContent = "Open External Signer";
  document.body.appendChild(anchor);
}
</script>

The same request over the Intent flow, as a transcript. Orange lines go to the signer. Blue lines come back:

Intent session - client and com.example.signer
-- connect: no package yet --
→ launch nostrsigner:  {type: "get_public_key", permissions: [{"type":"sign_event","kind":1}]}
← RESULT_OK  {result: "b1a649ebe8…", package: "com.example.signer"}

-- sign: addressed to the stored package --
→ launch nostrsigner:{"kind":1,"content":"test",…}  package=com.example.signer
     {type: "sign_event", id: "req-1", current_user: "b1a649ebe8…"}
← RESULT_OK  {result: "e8551d85…", id: "req-1", event: "{\"id\":\"5c83…\",\"sig\":\"e8551d85…\",…}"}

-- a rejection looks like this --
← RESULT_OK  {rejected: true}

9 Version History

greenart7c3 wrote this NIP for the Amber signer. It entered the repository as a draft numbered 100 and became NIP-55 in mid-2024. Notable changes, newest first:

  • 20262026-06Full rewrite: shorter text, one shared methods table, clearer transport sections.
  • 20262026-01Fixed the nip44_encrypt return field; clarified the connection handshake (store the pubkey and package name once).
  • 20252025-10Documented get_public_key over the Content Resolver.
  • 20252025-02Added the web-flow warning, the rejected checks in the samples, and the package-name usage rules. Removed the get_relays method.
  • 20242024-11Added batch signing: intent flags, singleTop launch mode, and the results array.
  • 20242024-10Renamed the return field from signature to result.
  • 20242024-06fiatjaf renumbered the document from NIP-100 to NIP-55. Weeks earlier, web requests moved from intent: to nostrsigner: URLs.
  • 20242024Typo fixes, heading cleanup, and review edits.
  • Origin2023-11greenart7c3 published the first draft, with permissions and package-name discovery added in the same month.

Original Proposal - Credits

Authorgreenart7c3
First commit2023-11-08 — “Android Signer Application nip” (filed as 100.md; renumbered to 55.md on 2024-06-05)
Statusdraft - optional — for Android clients that delegate key handling
Source55.md on GitHub - full change history

10 See Also

  • NIP-01 — the event object, signatures, and kinds that the signer produces.
  • NIP-46 — remote signing over relays; the recommended option for web clients.
  • NIP-07 — the same capability model as a browser extension (window.nostr).
  • NIP-04 — the legacy encryption behind nip04_encrypt and nip04_decrypt.
  • NIP-44 — the current encryption behind nip44_encrypt and nip44_decrypt.
  • NIP-57 — zaps; decrypt_zap_event opens private zap requests.