What signing in with a wallet actually proves

26 August 2026

A wallet signature is a strange credential. It proves someone holds a private key. It does not, by itself, prove which account they hold the key for, or what they meant to authorise, or that they meant to do it now. A login built on one has to establish all three, and most of the interesting decisions are about closing those gaps rather than about the cryptography.

I built one of these for Akash provider onboarding — operators sign in with Keplr, then hand the service SSH access to machines they want to turn into compute providers. The blast radius of getting the auth wrong is not “someone reads your profile”. It is “someone else’s server, with your credentials on it”.

The shape

Two services. One holds an RSA private key and does nothing but turn a verified wallet signature into a signed session token. The other is the actual API, and it only ever sees the public key — it verifies the token, pulls the akash address out of the sub claim, and treats that as the caller’s identity for every route.

The split is the cheap part, and worth doing anyway. The API is the service with the large attack surface: it parses uploads, opens SSH connections, shells out. If it is compromised, the attacker gets everything that service can reach — but they cannot mint themselves a token for somebody else’s address, because the key that signs tokens was never in that process.

Decision one: derive the address, don’t accept it

The client sends three things: a signer address, a public key, and a signature. The obvious implementation verifies that the signature matches the public key, and then trusts the address.

That is an impersonation hole. Nothing in “this signature is valid for this public key” says the public key belongs to the address in the same request. I can sign a message with a key I control, claim I am akash1… belonging to you, and the signature checks out perfectly — because it was never checked against your address.

So the address is not accepted. It is derived:

const publicKeyHash = SHA256(lib.WordArray.create(publicKey)).toString();
const publicKeyRIPEMD160 = RIPEMD160(enc.Hex.parse(publicKeyHash)).toString();
const publicKeyHex = new Uint8Array(Buffer.from(publicKeyRIPEMD160, 'hex'));
const address = bech32.encode('akash', bech32.toWords(publicKeyHex));

return signer === address;

SHA256, then RIPEMD160, then bech32 with the akash prefix — the same derivation the chain uses. If the address you claim is not the address your key produces, the request is over before any signature is checked. The public key type is pinned to tendermint/PubKeySecp256k1 in the same breath, so nobody gets to bring an algorithm the derivation was not written for.

Decision two: the server decides what was signed

The second hole is subtler. If the client sends the message it signed, the client chooses the message. A signature over “hello” is a perfectly valid signature; it just does not mean the person intended to log in. Worse, a signature captured from one context can be replayed into another if both accept arbitrary text.

So the message is never transmitted. The server reconstructs it, from data it already holds:

{app host} wants you to sign in with your Keplr account - {signer} using Nonce - {nonce}

The app host is server config. The signer is the address just derived from the public key. The nonce came from the server’s own database, issued to that address earlier. The client contributes nothing to the message except, indirectly, its identity — and that was already proven in decision one.

That string is wrapped in an amino StdSignDoc with chain_id: '', account_number: '0' and sequence: '0' — the ADR-36 convention for signing arbitrary data rather than a transaction. The empty chain ID matters: it is what makes this document unusable as a real transaction on any chain. You are asking a wallet that normally moves money to sign something that provably cannot move any.

Then it is serialised, hashed, and verified against the public key. If the user signed anything other than exactly that sentence, the digests differ and verification fails.

The nonce is the “now”

The nonce closes the third gap: recency. Without it, a signature over “I want to log in” is valid forever, and anyone who captures it once can replay it into a session whenever they like.

Each address has a nonce. It goes into the signed message, so the signature is only good for that one challenge. The moment a signature verifies, the nonce is rotated — so the signature that just worked will never work again, including for the person who legitimately produced it.

There is a design tension here worth naming: the nonce is publicly readable. Any caller can ask for the nonce belonging to any address. That looks wrong until you notice the nonce is a challenge, not a secret. Knowing it buys you nothing, because you still cannot produce a signature over it without the private key. Making it secret would mean authenticating the request that fetches it, which is circular — you would need a session to start a session.

Sessions, and the part everyone skips

Verification produces a short-lived access token and a long-lived refresh token, both RS256. The access token carries issuer, audience, subject and expiry, and the API checks all of them — an audience check is what stops a token minted for one service being replayed against another.

Refresh tokens rotate: each use issues a new one and the old one stops working. The part worth the extra table is what happens when an old one comes back anyway. Every refresh token issued to a session shares a family ID. Presenting a token that belongs to a live family but is not the current one means two parties hold tokens from the same session — which, in the absence of a clock-skew story, means one of them stole it. The response is to invalidate the entire family rather than just reject the request, logging out the thief and the legitimate user together.

That is deliberately blunt. You cannot tell which of the two is the attacker, so you end the session and make both re-authenticate. Re-signing with Keplr costs the real user a click. It costs the attacker everything, because they do not have the key.

What it does not solve

Session hijack after the fact. All of this establishes that the person who started the session controlled the key at that moment. It says nothing about who is holding the bearer token twenty minutes later — which is why the access token is short-lived and the refresh family is watched, rather than why it is not a problem.

And it establishes control of a key, which is not the same as identity. A wallet is not a person. If the key moves, the account moves with it, silently and with no recovery path. That is a property of the model, not a bug in the implementation, and it is the right trade for provider onboarding — where the address is the thing being authorised, because it is the address that gets paid.

← All writing