NKIPs

NKIP-0003 · Draft

Sign-In with Monero

Abstract. A site proves that a user controls a Monero address by asking the user's wallet to sign a short, fixed-format message. No funds move. It is the Monero counterpart of Sign-In with Ethereum (EIP-4361), built on the message signatures every Monero wallet already makes (sign / verify).

NKIP3
TitleSign-In with Monero
StatusDraft
TypeStandards Track
Created2026-10-27
LicenseCC0-1.0
RequiresRFC 3986, RFC 8259

Files: challenge.schema.json · vectors/v1.json

Contents

Motivation

Sites that identify users by a Monero address (payout profiles, creator pages, paid APIs) need a login that doesn't depend on an email address, a phone number or a third-party account. Today each site invents its own "sign this text" format, and wallets can't show the user in a consistent way what they are approving.

Goals:

  • G1 A verifier can check, with nothing but a public Monero address, that its holder approved signing in to one named site, once, within a few minutes.
  • G2 The wallet builds the text it signs from named fields, so it can show the user exactly what the signature means, and a signature for one site can't be used at another.
  • G3 Any wallet that can make Monero message signatures can take part, by deep link, QR code or copy-paste.
  • G4 The signature hands the site a key of the site's own choosing (a device key), so the site never needs the wallet again until the next sign-in.

Non-goals:

  • Payments (see the XMR402 protocol, which proves control by paying).
  • Linking a Monero address to a real-world identity.
  • Session management after sign-in; that is the site's own business.

Roles

Role Does
Site (relying party) The web page the user signs in to. Generates the device key pair, asks the verifier for a challenge, opens the challenge URI, waits for the result.
Verifier The site's backend (it may run on another host). Issues challenges, stores their state, checks signatures, binds the device key on success.
Wallet Holds the address's keys. Parses the challenge, shows it to the user, signs on explicit approval, posts the signature to the callback.

Specification

The key words MUST, SHOULD and MAY are used as in RFC 2119.

Challenge URI

monero-signin:?address=<ADDRESS>&nonce=<NONCE>&session=<SESSION>&origin=<HOST>
  &purpose=<PURPOSE>&handle=<HANDLE>&dk=<DK>&issued_at=<TIME>&expires_at=<TIME>
  &return_url=<CALLBACK>&v=1

The query is application/x-www-form-urlencoded; values are percent-decoded before any check, and + decodes to a space. Verifiers MUST percent-encode every value they put in the URI (so a literal + travels as %2B). A wallet MAY also accept the same query under its own URI scheme.

Parameter Required Rule
address MUST Mainnet standard address or subaddress (95 characters, starts with 4 or 8). Integrated addresses are refused.
nonce MUST Verifier-chosen, at least 128 bits of randomness, 22–128 characters of [A-Za-z0-9_-] (22 base64url characters carry 128 bits; a hex nonce MUST be at least 32, which wallets can't tell apart, so this is the verifier's duty; wallets refuse anything else, since they can't check the randomness).
session MUST Verifier-chosen opaque id of this challenge, 1–128 characters of [A-Za-z0-9_-]; echoed in the callback. Not part of the message.
origin MUST Bare host of the site the user is signing in to, with an optional port: [a-z0-9.-]+(:port)?. No scheme, path or user info. Internationalized names travel as xn-- (punycode), and wallets show them in that form, never converted to Unicode.
purpose SHOULD Short word for what the sign-in is for: 1–32 characters of [a-z0-9_-]. When it is absent, wallet and verifier both use login, and the message says Purpose: login.
handle MUST The account name at the site the user is signing in as: 1–64 Unicode code points, none of them in a Unicode Other (C*: control, format, surrogate, private use, unassigned) or Separator (Z*: spaces, line and paragraph separators) category, none with the Default_Ignorable_Code_Point property (this covers variation selectors), and none of the blank-rendering letters U+115F, U+1160, U+3164, U+FFA0 or U+2800. The value MUST already be in Unicode NFC form, and verifiers look accounts up by the NFC handle. This rules out right-to-left overrides (U+202A–U+202E, U+2066–U+2069), zero-width characters (U+200B–U+200D) and spaces.
dk MUST Device-key fingerprint: the first 16 lowercase hex characters of SHA-256 over the device public key as the verifier stores it (for example the 64-character lowercase hex of an Ed25519 public key).
issued_at MUST UTC time, YYYY-MM-DDTHH:MM:SS[.sss]Z.
expires_at MUST Same format. issued_at < expires_at ≤ issued_at + 15 minutes.
return_url MUST The verifier's callback, generated by the verifier (never by the page or the wallet). https:; http: only to localhost, 127.0.0.1 or [::1]. No user name, password or # fragment.
v MUST 1 for this version.

A wallet MUST refuse the whole challenge, and sign nothing, when:

  • a required parameter is missing or empty, or any parameter appears twice;
  • any value contains a control character (U+0000–U+001F, U+007F), since a line break would add lines to the signed message, or, in handle and purpose, any character outside their rules above, since invisible or direction-changing characters would make the wallet show something other than what is signed;
  • origin, dk, address, the times or return_url break the rules above;
  • v is not a version it implements (there is no fallback signing);
  • the query carries a parameter this version doesn't define. v1 is strict; a later version that adds parameters changes v.

Verifiers SHOULD keep the whole URI short enough for a QR code (under about 600 characters), mainly by keeping return_url short.

Canonical message (v1)

The wallet builds the message itself from the parameters and signs exactly these UTF-8 bytes: lines joined by a single LF (0x0A), no trailing newline. It never signs a text supplied by the site.

<origin> wants you to sign in with your Monero address:
<address>

Sign in to <origin> as @<handle>. This proves you control this address. No funds move.

Purpose: <purpose>
Handle: <handle>
Nonce: <nonce>
Device-Key: <dk>
Issued-At: <issued_at>
Expiration-Time: <expires_at>
Version: 1

Device-Key is in the message so the signature names the one device key it authorizes: a verifier whose stored state was altered after signing can't bind a different key, and a wallet can show the user that the key came from the page they are on when it knows that page. The verifier stores the full key; the 64-bit fingerprint only has to rule out collisions with keys the attacker doesn't control.

session and return_url are deliberately not in the message. The callback is authenticated by the verifier's own state, not by the signature: the signature covers the nonce, and the verifier maps that nonce to exactly one stored challenge with one device key (see Verifier rules). Wherever the signature is delivered, it can only complete that one challenge.

A future version changes the Version: line, so signatures of different versions never collide.

Signature

A Monero message signature (SigV1… or SigV2…) over the message bytes, by the spend key of address (for a subaddress, its derived spend key), as monero-wallet-rpc sign makes with signature_type: spend.

  • Wallets SHOULD produce SigV2 (domain-separated and bound to the address's spend and view keys). SigV1 is accepted for existing wallets and is deprecated.
  • Verifiers MUST accept both, and MUST accept only spend-mode signatures. A view-mode signature proves only that someone holds the view key, so it is refused. monero-wallet-rpc verify reports good: true for view-mode signatures too, so a verifier using it MUST require both good: true and signature_type: "spend". A verifier whose verify doesn't report the type MUST check the signature against the address's public spend key directly, as tools/nkip0003.py does.
  • The wallet signs with the keys of exactly the address in the URI. With monero-wallet-rpc sign that means passing the account_index and address_index of that address (looked up with get_address_index).

Callback

The wallet sends:

POST <return_url>
Content-Type: application/json

{"session": "<session>", "address": "<address>", "signature": "<SigV…>"}

The verifier answers 200 {"ok": true} on success, or a 4xx with {"ok": false, "error": "<text>"}. The error text, status code, size and timing MUST NOT reveal which check failed. Web wallets post across origins, so the callback MUST answer CORS preflight requests allowing POST with Content-Type: application/json from any origin; the challenge state, not the caller's origin, authenticates the request. How the site learns the result (polling, push) is up to the site and the verifier.

Verifier rules

A verifier MUST:

  1. Bind the challenge to an account it can check. The authenticated principal is the pair (account, address): the verifier MUST refuse to issue a challenge whose address isn't the address the account named by handle holds, and MUST check this again at callback time. (An unclaimed handle MAY be created by its first successful sign-in; that is registration, and the site decides whether it allows it.) This is what stops an attacker from asking for a challenge as @victim with their own address.
  2. Generate nonce, session, both times and return_url itself, and store them with address, origin, handle, purpose and the full device public key, before returning the challenge URI. It stores issued_at and expires_at as the exact strings it put in the URI and rebuilds the message from those strings, never by re-formatting a timestamp.
  3. Enforce expiry with its own clock, with a lifetime of at most 15 minutes (3 minutes RECOMMENDED). The wallet's time checks are only a convenience.
  4. Accept each challenge at most once: a successful callback consumes it. Failed callbacks don't, so someone who only saw the URI (over a shoulder, in a log) can't cancel the user's sign-in with a bad signature. The verifier rate-limits callbacks per challenge and per source instead, and deletes challenge state when it expires.
  5. Rebuild the message from the stored state, never from the callback body, and require the callback's address to equal the stored one.
  6. Verify the signature as above, with the stored address.
  7. Recompute dk from the stored device key and refuse if it differs from the signed dk. On success, bind exactly the stored device key (the one dk was computed from) to the account, and nothing else.

A verifier SHOULD NOT log nonces, signatures or full challenge URIs, and SHOULD rate-limit challenge requests. It SHOULD run on the origin site or a subdomain of it. Another host is allowed, and the wallet shows it.

Wallet rules

A wallet MUST:

  1. Refuse invalid challenges (see Challenge URI).
  2. Show before signing: origin, handle, the address (or which of the user's accounts it belongs to), the callback host, whether that host is the origin's own site, the expiry time and the full message. "Own site" means the callback host and origin have the same registrable domain under the Public Suffix List (so a.github.io and b.github.io are different sites); ports are ignored.
  3. Tell the user to approve only a sign-in they started themselves, just now, on the site whose address is in their browser's address bar (see Security considerations).
  4. Require an explicit approval for each signature; never sign automatically.
  5. Refuse when it doesn't hold the spend key for address.
  6. When it knows which page opened the challenge (a browser-extension or web wallet), refuse unless that page's host has the same registrable domain as origin. This closes relay phishing for those wallets; a separate wallet app that only receives a link or QR code can't do this check.

A wallet SHOULD refuse a challenge whose expires_at has passed or whose issued_at is more than 5 minutes in the future by its own clock.

Flow

Site                          Verifier                         Wallet
 | 1. make device key pair       |                                |
 | 2. request challenge -------->| store state, build URI         |
 |<------------- challenge URI --|                                |
 | 3. open URI (link or QR) ---------------------------------------->|
 |                               |       4. parse, show, approve,  |
 |                               |          build message, sign    |
 |                               |<---------- 5. POST return_url --|
 |                               | 6. rebuild, verify, consume,    |
 |                               |    bind device key              |
 | 7. learn result (poll/push) ->|                                |
 | 8. signed in with device key  |                                |

Requirements

ID Level Goal Requirement Acceptance check
R1 MUST G2 The wallet builds the v1 message byte-for-byte from the parameters; no trailing newline Test vectors: message and SHA-256
R2 MUST G2 The wallet refuses challenges with control characters, invisible or direction-changing characters in handle/purpose, a non-NFC handle, an unknown parameter, a nonce outside [A-Za-z0-9_-], repeated or missing parameters, bad origin/dk/address/times/callback, or an unknown v Test vectors: invalid challenges
R3 MUST G1 Verifiers accept SigV1 and SigV2, spend mode only (good and signature_type: spend, or a direct spend-key check) Test vectors: valid signatures and a view-mode signature
R4 MUST G1 Verifiers rebuild the message from stored state (exact time strings), enforce expiry with their own clock, and accept each challenge once; failed callbacks don't consume it Code review; replay test
R11 MUST G1 Verifiers issue and complete challenges only when address is the address the handle's account holds (or the handle is unclaimed and the site allows registration) Code review; takeover test
R5 MUST G4 On success the verifier binds exactly the stored device key Code review
R6 MUST G2 The wallet shows origin, handle, address, callback host (flagged when not the origin's own site), expiry and the full message, plus the approve-only-if-you-started-it warning UI review
R7 MUST G3 Challenges work as a deep link, a QR code and pasted text with the same query Manual
R8 SHOULD G1 Wallets produce SigV2 Code review
R10 MUST G2 A wallet that knows the opening page refuses when its registrable domain differs from origin Extension test
R9 MUST before leaving Draft — Schema, test vectors and a checker in CI CI on this repo

Security considerations

Threat Likelihood Control
Relay phishing: an attacker starts a real challenge for the victim's account with the attacker's device key and gets the victim to approve it, e.g. from a page that looks like the site High The protocol can't prevent this: the message is honest (right site, right account) and the attacker can use the real callback. Controls: the wallet's warning (R6: approve only a sign-in you started, on the site in your address bar); short lifetimes; sites SHOULD notify the account owner of every new sign-in and show and let them revoke device keys. dk does not help here, since a user can't tell whose key a fingerprint belongs to
Account takeover: an attacker requests a challenge as @victim with their own address and signs it High R11: the verifier refuses a challenge whose address isn't the account's, at challenge and callback time
Signature sent to a callback on another host Medium Harmless by construction: the signature covers the nonce, which completes only its own challenge. If the attacker made that challenge they could have used the real callback; if the victim made it, it binds only the victim's own device key. The wallet still shows and flags the host
Replay of an old signature Medium Single-use challenge, verifier clock, ≤ 15 min lifetime (R4)
Cross-site replay Medium origin is in the message; a verifier for another site rebuilds a different message
Lines injected into the message (%0A in a value) Medium Control characters refused (R2), so every field stays on its own line
Display spoofing: a right-to-left override or zero-width character makes the wallet show a different handle or site than the signed bytes Medium handle and purpose character rules (R2); origin is ASCII and shown as punycode
Someone who saw the URI cancels the sign-in with a bad callback Low Failed callbacks don't consume the challenge (R4); per-challenge rate limits
Verifier trusts the callback body Medium Message rebuilt from stored state only (R4)
View-key holder signs in (watch-only wallet, auditor) Medium Spend-mode signatures only (R3)
Wrong wallet clock Low The verifier's clock decides (R4); the wallet's checks only catch obvious mistakes
SigV1 lacks domain separation (the same hash scheme as other Monero signatures over arbitrary data) Low The message is fixed-format text starting with the origin, never a hash; SigV2 recommended (R8)
Callback over plain HTTP Low https: only, except loopback for local testing

Privacy. A sign-in reveals the address to the site, which already knows it. The wallet's callback reveals the wallet's IP address to the verifier; wallets MAY send it over Tor or another proxy. Nothing is published.

Open questions

  • Register monero-signin: (or a shorter scheme) as a provisional URI scheme with IANA.
  • Multiple device keys per account (adding a key without removing the previous one) is left to sites; should v2 carry a Device-Label line?
  • v2: put the full SHA-256 of the device key (or the key itself) in the message instead of a 64-bit fingerprint.
  • Machine sign-in for agents (no human approval) needs its own key-custody rules; out of scope for v1.

Test vectors

nkip-0003/vectors/v1.json, checked by python3 tools/nkip0003.py check nkips/nkip-0003/vectors/v1.json (needs pycryptodome). They contain:

  • two valid challenges (a primary address with the callback on another site; a subaddress with the callback on a subdomain), with the exact message, its SHA-256, the device key behind dk, a SigV2 from monero-wallet-rpc v0.18.4.5 sign, and a SigV1, all checked with its verify;
  • twenty-five invalid challenges with the reason each is refused (including a right-to-left override and a zero-width space in handle, a + in nonce, an uppercase purpose, an unknown parameter, a non-NFC handle and a Hangul filler in handle);
  • two signatures that must fail: a view-mode signature, and a primary-address signature checked against the subaddress.

The addresses belong to a throwaway offline test wallet whose private keys are in the file. Never send funds to them. Signatures are randomized, so implementations check that these verify, not that they reproduce them.

Implementations

Add yours with a pull request. Projects by this NKIP's authors are marked.

Project Role Status
kyc.rip "Sign in with Ripley" (authors' project) Verifier v1 message byte-identical. Not yet conforming to R4: failed callbacks still consume the challenge
Ripley Terminal (authors' project) Wallet Builds and signs the v1 message

Changelog

  • 2026-10-27: Review round 2 (OpenCode 8.3, pass): Default_Ignorable and blank-rendering characters refused in handle, verifier-side hex-nonce minimum, explicit dk recompute, R2 completed.
  • 2026-10-27: Review round 1 (OpenCode 7.7): account binding as a MUST (R11), explicit signature_type: spend check, get_address_index for sign, nonce at least 22 characters, strict unknown-parameter refusal, NFC handles, CORS on the callback, no leaking which check failed, failures never consume a challenge, Device-Key rationale, QR length.
  • 2026-10-27: Review fixes: handle/purpose/nonce character rules (no invisible or direction-changing characters), punycode display, + encoding, exact time strings, explicit purpose default, failed callbacks no longer consume a challenge until the fifth, same-site by registrable domain, page-origin check for wallets that know the opening page (R10).
  • 2026-10-27: Draft. Message format and parameters from the existing "sign in with a Monero wallet" deployment (unchanged bytes, so it conforms), with the rules that deployment left implicit made explicit: every parameter required, control characters, repeated parameters, short nonces and callbacks with credentials or fragments refused, unknown versions refused, spend-mode signatures only, verifier-side expiry and single use, and the relay-phishing limit stated instead of claimed solved. Old links without return_url or v are not valid challenges.

Markdown source · History · Comment