← All NIPs

NIP-55

Android Signer Application

DraftBehaviour

Lets Android apps (and web pages opened on Android) ask a separate signer app to sign events and encrypt or decrypt messages, so only the signer ever holds the secret key. Requests go through intents, a background content resolver or nostrsigner: links.

At a glance

Status
In use and still allowed to change.
Last changed
Jun 3, 2026

Try it

Edit the form or the JSON: both stay in sync, the badge says whether the result is valid, and tapping any field explains it.

How this NIP works

Step 1 of 6

Find the signer

An Android app declares the nostrsigner scheme in its manifest queries and checks that some app handles nostrsigner: intents.

Who does what

Who does what
UserNostr appSigner app
Step 1 of 10

Ready. Press play or step forward to send the first message.

Text version
#FromToMessage
1Nostr appSigner appThe app opens a nostrsigner: intent with type get_public_key. It may list permissions to pre-authorise, such as signing kind 22242 or nip44_decrypt.
2UserSigner appThe signer opens and the user chooses which account to share and which permissions to remember.
3Signer appNostr appThe signer returns the user pubkey in result and its package name in package. The app stores both and addresses every later request to that package.
4Nostr appSigner appThe event JSON goes in the nostrsigner: URI; type, an optional id and current_user go as intent extras.
5UserSigner appThe user approves or rejects. A rejection comes back as RESULT_OK with rejected = true; any other result code means the signer failed.
6Signer appNostr appExtras: result (the signature), id (echoed) and event (the signed event JSON).
7Nostr appSigner appFor remembered permissions the app queries content://<package>.SIGN_EVENT with selectionArgs [payload, pubkey, current_user] and the signer never opens.
8Signer appNostr appA cursor with result (and event for sign_event). null or a rejected column means "not remembered" or "always reject"; do not fall back to an intent after a rejection.
9Nostr appSigner appWeb pages cannot receive intent results, so the signer appends the result to callbackUrl or copies it to the clipboard. compressionType gzip returns "Signer1" + base64(gzip(event)).

In the course

No course chapter covers this NIP yet. The editor above is the quickest way in.

See also

Specification

Specification text is quoted in English from the nostr-protocol/nips repository. Our explanations and the interface are translated. Source on GitHub at commit 0046368

Read the spec1,194 words, quoted from the repository at commit 0046368.

Rationale

This NIP describes a method for 2-way communication between an Android signer and any Nostr client running on the same device, so that the client never needs to handle the user's private key. The signer is an Android application; the client may be another Android application or a web page.

Terminology

  • user: A person trying to use Nostr.
  • client: A user-facing application that sends signing requests.
  • signer: An Android application that holds the user's private key and answers requests from client.
  • user-pubkey: The public key representing user, returned by get_public_key.
  • package name: The Android package name of the signer (e.g. com.example.signer), used by client to address subsequent requests.

All pubkeys in this NIP are in hex format.

Communication methods

A client can talk to the signer in three ways:

  • Intents — used by Android clients. The signer opens, the user approves or rejects the request manually, and the result is returned to client.
  • Content Resolver — used by Android clients. Runs in the background without opening the signer, but only works for permissions the user has previously chosen to remember.
  • Web — used by web clients. The result is returned via a callbackUrl or copied to the clipboard.

Setup

To detect and call the signer, an Android client must declare the nostrsigner scheme in its AndroidManifest.xml:

<queries>
  <intent>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="nostrsigner" />
  </intent>
</queries>

It can then check whether a signer is installed:

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

Initiating a connection

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

A client MAY pass a list of permissions with get_public_key so the user can pre-authorize methods (used by the Content Resolver to answer in the background). Each permission is an object with a type (a method name) and, for sign_event, an optional kind:

[{ "type": "sign_event", "kind": 22242 }, { "type": "nip44_decrypt" }]

Methods

Every request has a type (the method name) and a payload. The payload is passed differently per transport (see below) but its meaning is shared:

Method Payload Extra params Result
get_public_key (empty) permissions user-pubkey
sign_event event JSON current_user the signature, and signed event
nip04_encrypt plaintext pubkey, current_user the ciphertext
nip44_encrypt plaintext pubkey, current_user the ciphertext
nip04_decrypt ciphertext pubkey, current_user the plaintext
nip44_decrypt ciphertext pubkey, current_user the plaintext
decrypt_zap_event event JSON current_user the decrypted event JSON
  • current_user is the user-pubkey currently logged in to client, in hex format.
  • pubkey is the public key of the other party used for encryption/decryption, in hex format.
  • id is an optional client-chosen string echoed back in the result, used to match responses when several requests are sent without waiting.

Using Intents

The payload is the nostrsigner: URI data; all other params are intent extras. The result is returned via registerForActivityResult / rememberLauncherForActivityResult.

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 result is read from the returned intent's extras:

Extra Description
result the method result (pubkey, signature, ciphertext, etc.)
id the id sent in the request
event the signed event JSON (sign_event only)
package the signer package name (get_public_key only)
rejected true when the user rejected the request

A resultCode other than Activity.RESULT_OK means the signer failed (e.g. it crashed), not that the user rejected the request. When the user rejects, the signer returns RESULT_OK with a rejected extra set to true.

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")
  }
}

To sign multiple events without reopening the signer for each one, client adds the flags below and the signer returns an array of results. The signer must declare android:launchMode="singleTop" for this to work.

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())

Using Content Resolver

The client queries content://<package-name>.<TYPE> (e.g. SIGN_EVENT, NIP44_ENCRYPT). The query's selectionArgs carry the payload and params in the order [payload, pubkey, current_user]:

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 (and, for sign_event, an event column with the signed event JSON). The query returns null, or a rejected column is present, when:

  • 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, or
  • the user chose to always reject this request (a rejected column is returned — client SHOULD NOT then fall back to an Intent).
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 and avoid calling get_public_key after the user is logged in.

Using Web Applications

Web clients can't receive a result from an intent, so the signer returns the result via a callbackUrl (appended to the URL) or, if none is given, by copying it to the clipboard. The request is a nostrsigner: URL whose path is the payload and whose query string carries the params:

nostrsigner:<payload>?type=<method>&pubkey=<hex_pubkey>&callbackUrl=https://example.com/?result=

Query parameters:

  • type — the method name.
  • pubkey — the other party's hex pubkey (encryption/decryption methods).
  • callbackUrl — where the signer appends the result. If omitted, the result is copied to the clipboard.
  • returnType — signature or event.
  • compressionType — none (default) or gzip. With gzip the returned event is "Signer1" + Base64(gzip(event json)); useful because intents and URLs have length limits.
window.href = `nostrsigner:${eventJson}?compressionType=none&returnType=signature&type=sign_event&callbackUrl=https://example.com/?event=`;

Consider using NIP-46: Nostr Remote Signing for web applications. With the approach here the web client can't call the signer in the background, so the user sees a popup for every request.

Example

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h1>Test</h1>

    <script>
        window.onload = function() {
            var params = new URL(window.location.href).searchParams;
            var result = params.get("event");
            if (result) alert(result);

            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>
</body>
</html>