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).
| NKIP | 3 |
|---|---|
| Title | Sign-In with Monero |
| Status | Draft |
| Type | Standards Track |
| Created | 2026-10-27 |
| License | CC0-1.0 |
| Requires | RFC 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
handleandpurpose, 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 orreturn_urlbreak the rules above;vis 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).SigV1is 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-rpcverifyreportsgood: truefor view-mode signatures too, so a verifier using it MUST require bothgood: trueandsignature_type: "spend". A verifier whoseverifydoesn't report the type MUST check the signature against the address's public spend key directly, astools/nkip0003.pydoes. - The wallet signs with the keys of exactly the address in the URI. With
monero-wallet-rpcsignthat means passing theaccount_indexandaddress_indexof that address (looked up withget_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:
- 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
addressisn't the address the account named byhandleholds, 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@victimwith their own address. - Generate
nonce,session, both times andreturn_urlitself, and store them withaddress,origin,handle,purposeand the full device public key, before returning the challenge URI. It storesissued_atandexpires_atas the exact strings it put in the URI and rebuilds the message from those strings, never by re-formatting a timestamp. - 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.
- 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.
- Rebuild the message from the stored state, never from the callback body,
and require the callback's
addressto equal the stored one. - Verify the signature as above, with the stored
address. - Recompute
dkfrom the stored device key and refuse if it differs from the signeddk. On success, bind exactly the stored device key (the onedkwas 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:
- Refuse invalid challenges (see Challenge URI).
- 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 andoriginhave the same registrable domain under the Public Suffix List (soa.github.ioandb.github.ioare different sites); ports are ignored. - 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).
- Require an explicit approval for each signature; never sign automatically.
- Refuse when it doesn't hold the spend key for
address. - 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-Labelline? - 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 frommonero-wallet-rpcv0.18.4.5sign, and a SigV1, all checked with itsverify; - twenty-five invalid challenges with the reason each is refused (including a right-to-left override and a zero-width space in
handle, a+innonce, an uppercasepurpose, an unknown parameter, a non-NFC handle and a Hangul filler inhandle); - 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, explicitdkrecompute, R2 completed. - 2026-10-27: Review round 1 (OpenCode 7.7): account binding as a MUST (R11), explicit
signature_type: spendcheck,get_address_indexforsign, 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-Keyrationale, QR length. - 2026-10-27: Review fixes:
handle/purpose/noncecharacter rules (no invisible or direction-changing characters), punycode display,+encoding, exact time strings, explicitpurposedefault, 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_urlorvare not valid challenges.