Server Authentication for web3
Miguel Piedrafita
0xE340…DCb39DOne of the big appeals of dApps is that the user owns their data. However, for this to be true, we need to authenticate the user with their web3 identity (their wallet). This is easy to do client-side (since they can submit all their tx's themselves using Metamask) but gets harder when we need to verify their identity from a server.
In this article, I’ll be outlining the technical implementation of a “Login with wallet” button, similar to the ones provided by Showtime or Foundation.
From wallet to server
The first implementation for this is pretty simple. We have the user connect their wallet to our frontend, and make an API request to our server with the retrieved wallet address.
The issue here is that anyone can send an API request with anyone else’s address to our API, and we have no way of verifying if that address matches the one connected to the frontend.
Crypto stands for Cryptography
It’s easy to forget that, in essence, crypto wallets are just a cryptographic key pair (a combination of a private and public key). When you create a transaction, you’re just signing the parameters of your transactions (to mathematically prove you’re the creator) and broadcasting it to the ETH network.
Luckily, transactions aren’t the only thing wallets can sign. We can create an arbitrary message (like Please sign this message to connect to Foundation.
), and verify the signature to make sure the wallet trying to authenticate was the one that signed our message.
Ethereum signatures are Keccak (SHA-3) hashes that start with Ethereum Signed Message:
. This allows us to perform our verification with a Keccak and an ECC (Elliptic Curve Cryptography) library on any programming language.
To make this work we’ll need three things: the address that we’re trying to authenticate, the message that we signed, and the signature, which we can get using any web3 library (ethers.js
in this example):
import axios from 'axios'
import { ethers } from 'ethers'
// On production, you should use something like web3Modal
// to support additional wallet providers, like WalletConnect
const web3 = new ethers.providers.Web3Provider(window.ethereum)
const message = "Sign this message to log in to our app"
await axios.post('/api/auth/login', {
address: await web3.getSigner().getAddress(),
signature: await web3.getSigner().signMessage(message),
})
On the server, we can then use eth-sig-util
to verify the message was signed by the submitted wallet, and authenticate it via a cookie or API token.
import { recoverPersonalSignature } from 'eth-sig-util'
const message = "Sign this message to log in to our app"
if (address.toLowerCase() !== recoverPersonalSignature({ data: data, sig: signature }).toLowerCase()) {
throw new Error('Authentication failed')
}
// wallet address has been verified, set a cookie (or return a token)
If you want to get a better grasp at how this verification works behind the scenes, you can check my PHP implementation of the signature verification code.
Protecting against replication attacks
We have a system that allows anyone to log in with their wallet, and a way to make sure you can’t authenticate as other people. But there’s an issue. Since we’re always signing the same message, any of those signatures serve as a perpetual key to our account, which never expires.
This means that, if someone were to intercept it via a MITM attack or by tricking us into signing that same message on a different site, they’d get non-revokable access to our account.
To prevent this, we need to make sure the message is different each time. The easiest way to do this is by generating a random string (nonce) and including it on the message.
We’ll first need to generate our nonce server-side and store it on the session (since we’ll need it to verify the signature later):
import crypto from 'crypto'
export default async function(req, res) {
req.session.nonce = crypto.randomInt(111111, 999999)
res.end(`Hey! Sign this message to prove you have access to this wallet. This won't cost you anything.\n\nSecurity code (you can ignore this): ${req.session.nonce}`)
}
Then, instead of hardcoding the message to sign, we retrieve it from the server via AJAX:
import axios from 'axios'
import { ethers } from 'ethers'
// On production, you should use something like web3Modal
// to support additional wallet providers, like WalletConnect
const web3 = new ethers.providers.Web3Provider(window.ethereum)
const message = await axios.get('/api/auth/nonce').then(res => res.data)
await axios.post('/api/auth/login', {
address: await web3.getSigner().getAddress(),
signature: await web3.getSigner().signMessage(message),
})
Finally, before checking the signature we’ll need to reconstruct the message by pulling our nonce from the session.
📦
As with everything, there are a few packages that can take care of this process for you. I recommend using passport-web3 on Node, and laravel-web3-login if you’re working with PHP and Laravel. If you find packages for other languages, DM me and I’ll add it here.