11

I'm making a dapp that will make calls to a Node.js server. I expect the user to have MetaMask installed and I want to verify that he is the real owner of the current address on MetaMask (i.e. accounts[0]).

This is the user flow I am trying to implement:

  1. User loads up my dapp frontend in his browser.
  2. The browser gets accounts[0] from Web3/MetaMask.
  3. The frontend requests some data specific to accounts[0] from my Node.js API.
  4. On the Node.js server, I need to verify that the request is coming from someone who actually owns the private keys to the address accounts[0]. If this is valid, then I respond with the specific data.

I spent a long time looking at different signing functions with Web3, and I ended up very confused. There is:

  • web3.eth.sign - there's no recover counterpart to this and MetaMask doesn't pop up asking the user to sign something.
  • web3.eth.personal.sign - this one requires a password, I don't want to ask the user to enter his password, shouldn't MetaMask do this?
  • web3.eth.accounts.sign - this seems more like a hashing function than what I need.

I have a feeling that none of the three above functions are what I need. Can someone provide some guidance on how to approach this?

2
  • I think the solution isn't working anymore. At least I don't get the right public address. Could you please help me finding out, how it has to be done? Jul 19, 2018 at 18:03
  • The answers on this page are out of date. web3 is no longer accessible on a page using Metamask.
    – Dave Stein
    Aug 17, 2021 at 20:52

5 Answers 5

15

I think web3.eth.sign is what you want, but note that it expects a 32-byte string (usually a hash of a message).

This worked for me:

web3.eth.sign(web3.eth.defaultAccount, web3.sha3('test'), function (err, signature) {
  console.log(signature);  // But maybe do some error checking. :-)
});

Then on the server, using ethereumjs-util:

const util = require('ethereumjs-util');
const sig = util.fromRpcSig('<signature from front end>');
const publicKey = util.ecrecover(util.sha3('test'), sig.v, sig.r, sig.s);
const address = util.pubToAddress(publicKey).toString('hex');

You said "MetaMask doesn't pop up asking the user to sign something," but it's supposed to. If it's still not working, please share the code you're using to call web3.eth.sign.

EDIT

Note that in web3.js 1.0, it's web3.util.sha3 instead of web3.sha3.

4
  • Thanks, I'm going to try this out. If it works, I'll accept your answer.
    – adrianmc
    Jan 8, 2018 at 4:58
  • this was very helpful, thank you. I had to make two changes to make it work though (as I am using Web3 1.0): (1) web3.utils.sha3 instead of web3.sha3, (2) fromRpcSig(), I needed to pass in the whole thing with the leading 0x. If you add this in an edit, I will accept your answer.
    – adrianmc
    Jan 8, 2018 at 5:28
  • 1
    Thanks, the leading 0x is indeed needed (regardless of which web3.js version you're using). I edited the answer.
    – user19510
    Jan 8, 2018 at 5:32
  • is this secure? Should the string be randomly generated from a server or can it be hard coded on the front and back end?
    – 0xD1x0n
    Oct 25, 2021 at 2:13
5

What I can say from my experience, is that none of the above replies worked out of the box for me. This is how I have implemented it (tested with MetaMask and WalletConnect)

Frontend web App (ReactApp)

Challenge is to sign a nonce (random string) provided by server. This is how a safe sign can be required

let sign = await web3.eth.personal.sign(nonce, walletAddress, "")

Where:

  • web3 is an instance of web3 (currently @ 1.3.6)
  • nonce is the random string to sign (passed as plain string)
  • walletAddress is the wallet address as string (ex. 0xaabb....ccdd)

The sign created is then sent as-is to server backend

Server backend verification

The goal is to extract from the sign the wallet address that signed the request. In this way, there's no way to fake it.

import * as util from "ethereumjs-util";

nonce = "\x19Ethereum Signed Message:\n" + nonce.length + nonce
nonce = util.keccak(Buffer.from(nonce, "utf-8"))
const { v, r, s } = util.fromRpcSig(signature)
const pubKey = util.ecrecover(util.toBuffer(nonce), v, r, s)
const addrBuf = util.pubToAddress(pubKey)
const addr = util.bufferToHex(addrBuf)

When we sign a nonce in the browser, a padding message is automatically added in front of the nonce, and so we need to re-created it server side in order to verify it.

What we do here is recreate the nonce, then extract the public key of the signer and obtain the wallet address from the public key. If that address matches the expected one, the user is the owner of the private key connected to address extracted.

ethereum-js has been user with version 7.0.10 (npm package)

6
  • What is the difference between signature and nonce on the back end? where did signature come from??
    – 0xD1x0n
    Oct 25, 2021 at 2:09
  • 1
    nonce is a random string to sign that is generated from the backend and sent to the frontend to sign. Signature is the output of a sign operation, and in our case, is generated using a wallet manager such as Metamask. These are not just web3-related concepts. I suggest you to read more about this topic, for example stackoverflow.com/questions/4751172/…
    – 70ny
    Oct 25, 2021 at 13:27
  • So it is considered unsecure using the same string on the front and back end instead of randomly generating it ?
    – 0xD1x0n
    Oct 25, 2021 at 15:36
  • 2
    Each nonce should be random and uniquely generated by backend, otherwise you'll have a security issue as I can use endlessly a stolen nonce signed by another user.
    – 70ny
    Oct 26, 2021 at 10:51
  • on the client side, this is the code I needed to use now that window.web3 is deprecated in favor of window.ethereum: window.ethereum.sendAsync({method: 'personal_sign', params: [window.ethereum.selectedAddress, nonce], from: window.ethereum.selectedAddress}, (err, result) => { console.log('signature', result.result) }); Dec 22, 2021 at 13:52
3

The MetaMask's own web3, still 0.2 asof May-2018, sign syntax is --

console.log(web3.version.api);
web3.personal.sign(web3.toHex("message to sign"), accounts[0], 
                   function(err, res) {
    // whatever
});

If you replace MetaMask's web3 with web3.js 1.0.0 (beta), the syntax to use is --

window.web3 = new Web3(web3.currentProvider);
console.log(web3.version);
web3.eth.personal.sign('message to sign', accounts[0])
.then(signature => {
    // whatever
});

In both cases, MetaMask will popup the sign notification

1

I implemented this and it works great. I am using Python/Flask in the backend so you would have to find equivalent backend code for Node:

Backend: store the user by their public address in the database along with a nonce used for signin

The most simple schema for a User/Account is:

public_address = db.Column(db.String(80), primary_key=True, nullable=False, unique=True)
nonce = db.Column(
    INTEGER(unsigned=True),
    nullable=False,
    default=generate_nonce,
)

Where generate nonce is a pseudorandom number generator something like:

def generate_nonce(self, length=8):
    return ''.join([str(randint(0, 9)) for i in range(length)])

Frontend GETs and signs the nonce with web3

Retrieve the public address of the current user:

web3.eth.getAccounts()
        .then((response) => {
            const publicAddressResponse = response[0];

            if (!(typeof publicAddressResponse === "undefined")) {
                setPublicAddress(publicAddressResponse);
                getNonce(publicAddressResponse);
            }
        })
        .catch((e) => {
            console.error(e);
        });

The frontend should do a GET request to retrieve the current nonce for the public address that is trying to sign in. If the account doesn't exist yet then create it and return the nonce anyway:

GET /api/users?publicAddress=${publicAddress}

and then sign the nonce with Metamask:

web3.eth.personal.sign(`I am signing my one-time nonce: ${nonce}`, publicAddress, "test password!")
            .then((signature) => {
                handleAuth(publicAddress, signature)
            });

Frontend then sends the signed nonce to the backend to receive a JWT

Frontend:

axios.post(props.config.serverUrl + '/sessions/', {
            publicAddress: publicAddress,
            signature: signature,
            auth_type: 'ethereum',
        })
            .then((response) => {
                localStorage.setItem('accessToken', response.data.access_token);
            })
            .catch((e) => {
                console.error(e);
            });

Then on the backend you authenticate that the signature came from that public address using web3 libraries and issue a JWT if authenticated. From there it is just normal session management using JWTs, which is not specifically a web3 issue:

@sessions_blueprint.route('/sessions/', methods=['POST'])
def create_session():

    auth_type = request.json.get('auth_type', AuthType.EMAIL)

    public_address = request.json['publicAddress']
    signature = request.json['signature']

    account = EthereumAccount.query.filter_by(public_address=public_address).first()

    if account is None:
        abort(404, 'Public address not registered.')

    original_message = 'I am signing my one-time nonce: {}'.format(account.nonce)
    message_hash = defunct_hash_message(text=original_message)
    signer = w3.eth.account.recoverHash(message_hash, signature=signature)

    if signer == public_address:
        account.nonce = account.generate_nonce()
        db.session.commit()
    else:
        abort(401, 'could not authenticate signature')

    access_token = create_access_token(identity=public_address)
    refresh_token = create_refresh_token(identity=public_address)


    return jsonify({
        'access_token': access_token,
        'refresh_token': refresh_token,
    }), 200

And voila! The user is now authenticated with a JWT on the client side. You can manage sessions and authorisation for routes on the backend however you normally do.

I learnt how to do this from this article: https://www.toptal.com/ethereum/one-click-login-flows-a-metamask-tutorial

0

I will explain in next.js for further reference:

1- Server has to create a message, create a session and client will request this and saves this in cookies. Using iron-session npm package makes it easier to create a session and also save the cookie in next.js.

  if (req.method === "GET") {
      try {
        // message can be anything. I use id as password
        const message = { contractAddress, id: uuidv4() };
        req.session.messageSession = {
          ...message,
        };
        await req.session.save();
        return res.json(message);
      } catch (error) {
        res.status(422).send({ message: "Cannot generate a message" });
      }

2- User will make a GET request. If you are creating an nft, you have to send json data and image, for both you need to do the verification process. That is why I write a reusable function to get the data and sign it:

const createSignedData = async () => {
    const messageToSign = await axios.get("/api/verify");
    const accounts = (await ethereum?.request({
      method: "eth_requestAccounts",
    })) as string[];
    // account will be the signer of this message
    const account = accounts[0];
    // password is the third param as uuid
    const signedData = await ethereum?.request({
      method: "personal_sign",
      params: [
        JSON.stringify(messageToSign.data),
        account,
        messageToSign.data.id,
      ],
    });
    return { signedData, account };
  };

3- After user creates the signed data it sends a POST request to the server:

const createNft = async () => {
    try {
      const { account, signedData } = await createSignedData();
      await axios.post("/api/verify", {
        address: account,
        signature: signedData,
        nft: nftMeta,
      })
    } catch (error: any) {
      console.log("error in createnft", error);
    }
  };

4- Server will get the signed data, now verification time. for this I write a middleware:

  export const addressVerificationMiddleware = async (
      req: NextApiRequest,
      res: NextApiResponse
    ) => {
      return new Promise(async (resolve, reject) => {
        const message = req.session.messageSession;
        // nonce is the representation of something that we are going to sign
        let nonce: string | Buffer =
          "\x19Ethereum Signed Message:\n" +
          JSON.stringify(message).length +
          JSON.stringify(message);
    
        nonce = util.keccak(Buffer.from(nonce, "utf-8"));
        const { v, r, s } = util.fromRpcSig(req.body.signature);
        // matching signature with the unsigned message
        const pubKey = util.ecrecover(util.toBuffer(nonce), v, r, s);
        const addressBuffer = util.pubToAddress(pubKey);
        const address = util.bufferToHex(addressBuffer);
        if (address === req.body.address) {
          resolve("Correct Address");
        } else {
          reject("Wrong Address");
        }
      });
    };

I create an endpoint and use the above middleware

if (req.method === "POST") {
      try {
        const { body } = req;
        const nft = body.nft as NftMeta;
        if (!nft.name || !nft.description || !nft.attributes) {
          return res.status(422).send({ message: "Form data is missing" });
        }
        // addressCheckMiddleware
        await addressVerificationMiddleware(req, res);
        const url = `https://api.pinata.cloud/pinning/pinJSONToIPFS`;
        const jsonResponse = await axios.post(
          url,
          {
            pinataMetadata: {
              name: uuidv4(),
            },
            pinataContent: nft,
          },
          {
            headers: {
              pinata_api_key: pinataApiKey,
              pinata_secret_api_key: pinataSecretApiKey,
            },
          }
        );
        return res.status(200).send(jsonResponse.data);
      } catch (error) {
        console.error("error in verify post req", error);
        res.status(422).send({ message: "Cannot create JSON" });
      }

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.