Mesh LogoMesh

Prove Wallet Ownership

Cryptographically prove account ownership by signing data with a private key. Use the public address as an identifier and build authentication based on message signing.

Use JSON Web Tokens (JWT) to pass authenticated user identity.

Example uses:

  • Authenticate sign-in: Prove account ownership.
  • Authenticate actions: Authorize off-chain actions.
  • Off-chain data: Display user-specific off-chain data.

How it works

cryptographically-prove-wallet-ownership-process

Signing a message affirms control of the wallet address.

Four ingredients are required:

  • User wallet address
  • Private key
  • Public key
  • Message to sign

To verify ownership, provide a message for the user to sign. Validate the signature using the public key.

Client: Connect Wallet and Get Staking Address

The backend User model requires public address and nonce fields. The address must be unique.

On Cardano, use the wallet's staking address as the identifier. Retrieve it using wallet.getUsedAddresses().

Get the user's staking address and send it to the backend:

const { wallet, connected } = useWallet();

async function frontendStartLoginProcess() {
  if (connected) {
    const userAddress = (await wallet.getUsedAddresses())[0];

     // do: send request with 'userAddress' to the backend
  }
}

Server: Generate Nonce and Store in Database

Generate a random nonce in the backend to create a unique authentication message. Use generateNonce() from Mesh.

Check the database for the userAddress. Create a new entry for new users. Store the new nonce for existing users.

import { generateNonce } from '@meshsdk/core';

async function backendGetNonce(userAddress) {
  // do: if new user, create new user model in the database

  const nonce = generateNonce('I agree to the term and conditions of the Mesh: ');

  // do: store 'nonce' in user model in the database

  // do: return 'nonce'
}

Return the nonce for signing.

Client: Verify ownership by signing the nonce

Sign the nonce using the wallet's private key with wallet.signData(nonce, userAddress) (CIP-8).

Request authorization. The app processes the generated signature.

async function frontendSignMessage(nonce) {
  try {
    const userAddress = (await wallet.getUsedAddresses())[0];
    const signature = await wallet.signData(nonce, userAddress);

    // do: send request with 'signature' and 'userAddress' to the backend
  } catch (error) {
    // catch error if user refuse to sign
  }
}

Server: Verify Signature

The backend retrieves the user and nonce from the database.

Verify the signature using checkSignature.

If verified, issue a JWT or session identifier.

Prevent replay attacks by regenerating the nonce after verification.

import { checkSignature } from '@meshsdk/core';

async function backendVerifySignature(userAddress, signature) {
  // do: get 'nonce' from user (database) using 'userAddress'

  const result = checkSignature(nonce, signature, userAddress);

  // do: update 'nonce' in the database with another random string

  // do: do whatever you need to do, once the user has proven ownership
  // it could be creating a valid JSON Web Token (JWT) or session
  // it could be doing something offchain
  // it could just be updating something in the database
}

Putting It All Together

Frontend implementation:

import { CardanoWallet, useWallet } from '@meshsdk/react';

export default function Page() {
  const { wallet, connected } = useWallet();

  async function frontendStartLoginProcess() {
    if (connected) {
      const userAddress = (await wallet.getUsedAddresses())[0];
      const nonce = await backendGetNonce(userAddress);
      await frontendSignMessage(nonce);
    }
  }

  async function frontendSignMessage(nonce) {
    try {
      const userAddress = (await wallet.getUsedAddresses())[0];
      const signature = await wallet.signData(nonce, userAddress);
      await backendVerifySignature(userAddress, signature);
    } catch (error) {
      setState(0);
    }
  }

  return (
    <>
      <CardanoWallet
        label="Sign In with Cardano"
        onConnected={() => frontendStartLoginProcess()}
      />
    </>
  );
}

Server-side implementation:

import { checkSignature, generateNonce } from '@meshsdk/core';

async function backendGetNonce(userAddress) {
  const nonce = generateNonce('I agree to the term and conditions of the Mesh: ');
  return nonce;
}

async function backendVerifySignature(userAddress, signature) {
  // do: get 'nonce' from database

  const result = checkSignature(nonce, signature, userAddress);
  if(result){
    // create JWT or approve certain process
  }
  else{
    // prompt user that signature is not correct
  }
}

This technique authenticates sign-ins or any user action.