Signing In With Ethereum: Owning Your Own Identity
With public key cryptography underlying Web3, one of the novel use cases which has risen is the ability to verify “who you say you are” without the need of a centralised identity provider. While there are many pieces being concurrently built in the rapidly evolving decentralised identity space, SpruceID is leading the charge on the authentication front.
SIWE (Sign In With Ethereum) has quickly established itself as an essential tool for users who demand more autonomy over their own digital identity. This is achieved through providing users the option to self-custodise their own digital identity via an EVM compatible wallet (ie. signing a verifiable message). Moreover, by establishing a standard sign-in workflow, SIWE can be easily integrated across existing identity services and is therefore not limited to just Web3 native applications.
SpruceID has provided a very useful SIWE Notepad Example which implements an end-to-end SIWE authentication on Express.js, complete with session management.
The purpose of this guide is to focus on just the core sign-in flow using Metamask. By setting aside session management as well as integration with other wallet providers, I hope to simplify the core aspects of SIWE :
- SIWE Authentication on Client vs Server
- Constructing the sign-in message for users to approve
- Avoiding replay attacks through nonce synchonisation
- Interacting with SIWE via the Metamask UI
The Github repository for this guide can be found here. If you would like an introduction to programatically interacting with Metamask:
Setting up an application skeleton
We first need to create a project directory and install Express:
mkdir MetamaskSIWE
cd MetamaskSIWE
npm install express
With the Express package installed, we can then utilise express-generator
to quickly and conveniently setup an app template. Before running express-generator
, we can view the setup options by running the following:
npx express-generator --help
For our purposes, we want to bootstrap our project using EJS as this minimises the need to context switch given its similarities to HTML. As such, we will run express-generator
with the EJS view flag:
npx express-generator -v ejs
As instructed, we will also install the required dependencies:
npm install
For the sake of convenience, we will be using nodemon
to automatically refresh our application whenever we make any changes. The command below will install nodemon
globally so that you can use it for your other projects:
npm install -g nodemon
Once installed, we can start our web application by running the below:
nodemon start
You should then be able to see a welcome page below when visiting the default port at localhost:3000
.
Express is now setup and we can go ahead and make the necessary changes to connect Metamask to our Express instance.
Create Login with SIWE Button
The first thing we will do is to change the view so that the page display is more intuitive for our purpose. Navigate into the index.ejs
file located in the /views
folder. We can replace the <body>
with the code below:
As per Metamask best practice, we have included a button for the user to initiate the connection request. The connection request should always be initiated by the user and not on page load.
Additionally, we have also included a bundle.js
script which we will be creating shortly. This script will hold the code required for Metamask to connect to the application.
Lastly, we have also changed the title that is fed into the page by modifying index.js
under the /routes
folder. The res.render()
feeds the data passed in the function to our index.ejs
file to be rendered.
Saving the above, nodemon
would have automatically refreshed your application and your browser should display the following:
We can now get started on implementing the Ethereum login logic.
Using SIWE to Sign-in from the Browser
In order to utilise the SIWE library, we will first install it via:
npm install siwe
Do note that SIWE comes pre-packaged with Ethers.js and hence there is no need to install Ethers.js again else there would be a conflict.
As we will need to run this script from the browser, we will also be using Browserify in order to require
the modules on the client’s browser. Refer to the Browserify section of the previous guide for more details on this.
Consequently, we will first create an input file, provider.js
, which contains all the logic required for this guide. This file will then be “Browserified” with the output file, bundle.js
, being placed in the /public/javascripts/
directory.
touch provider.js
We can then copy and paste the following into our provider.js
file:
We first instantiate our button by using the query selector and add a click event listener on the button. Once clicked, a few things will happen:
- Extract the
provider
andsigner
from the browser window using Ethers.js. This allows us to programatically interact with our Metamask instance. - Get the active Metamask account. This will be the address which is linked to the sign-in request.
- Generate a randomized nonce which will be used to validate the sign-in between the server and the client. Note that this is NOT the account nor consensus nonce which is used in Ethereum. To avoid the nonce being manipulated, this is generated on the server via calling the
/api/nonce
endpoint.
- Create a new SIWE message which will allow us to call the SIWE library methods on the message. You can find the code here.
- Prompt the user to sign the prepared SIWE message which will generate a receipt containing the raw signature of the message. You can refer to the Ethers.js
signMessage()
function here. - Call the
/api/sign_in
endpoint in order for the server to validate and handle the sign-in. As per session management best practices, we will be using the POST method here so that sensitive information is embedded in the request body.
Save the providers.js
file and we can then run the Browserify command:
browserify providers.js -o public/javascripts/bundle.js
A bundle.js
file will be created in the public directory which static assets are served from. Note that our index.ejs
file also contains a script tag referencing this path. Although nodemon
would have automatically refreshed our app, we still need to implement the /api/nonce
and /api/sign_in
before our sign-in is fully functional.
Updating our Server’s Endpoints
We will now switch to our index.js
file located under the routes/
directory to add our 2 endpoints.
The /api/nonce
endpoint will utilise the generateNonce()
function from the SIWE library which generates a sufficiently random nonce to prevent replay attacks. The code for generateNonce()
can be found here. Our endpoint will respond with the generated nonce:
Note that we have also initialised nonce
at the file level instead of saving it to a session for the sake of legibility. The nonce
is stored on the server in order to be able to compare it with the nonce
returned from the client when a user logs in.
The majority of the sign in code will be handled by the /api/sign_in
endpoint:
As per our client code, this endpoint is implemented as a POST request in order to minimise data leakages (this will have to be paired with HTTPS for better security). Consequently, we first need to destructure the request in order to get the message
and signature
. The message
is then formatted into the SiweMessage
equivalent. This SiweMessage
should contain the same information as the one generated in the browser.
In order to validate the signature
and message
pair, we also require a new provider
instance to be created. In this case, we will use Ethers.js default provider as we just need a quick way to test the sign in function.
The user login is then validated using the validate()
method on the SiweMessage
equivalent. The latest implementation can be found on SIWE’s Github. Do note that validate()
is soon to be deprecated in favour of verify()
which implements additional safeguards. However, as this latest change have yet to be implemented in NPM, this guide will still use the validate()
function.
Upon validation, the results are written to fields
. Critically, our code also implements a check to ensure that fields.nonce
is equivalent to the nonce
which was generated prior upon /api/nonce
being called. Once this check is done, the user has successfully signed in and we can handle the session accordingly. Additionally, a success response is also returned to the client which will be logged in the browser console.
Signing In With Metamask UI
We can now navigate our browser to test the sign in via the user interface. Click the “Login with SIWE” button and you should receive a signature request from Metamask.
Notice that the message defined in our provider.js
is displayed for the user to review. Additionally, you will also see the console printing relevant details such as the nonce
and the SiweMessage
.
You should also see the equivalent nonce
being generated and printed to your server logs.
We can then click the “Sign” button to approve this transaction. This will create the signed message which is printed to the browser’s console.
With this signature, the /api/sign_in
endpoint will also be triggered resulting in the following being printed to our server console:
Once completed, the success message returned by the server will be reflected accordingly in the browser console:
Congrats, you have successfully implemented signing in with Ethereum using Metamask!
Thanks for staying till the end. Would love to hear your thought/comments so do drop a comment. I’m active on twitter @AwKaiShin if you would like to receive more digestible tidbits of crypto-related info or visit my personal website if you would like my services :)