How to Build a Serverless Web3 Wallet Login like OpenSea with MetaMask and Cognito
Username / Password logins are the bane of our existence. Social logins? Just another tactic by big tech to collect our data.
So how do we improve this in a “Web3” way but still provide traditional JWT auth to your existing backend API?
Wallet sign in. This is the way.
The basic idea here is that it’s cryptographically easy to prove the ownership of an account by signing a message using a private key. If you manage to sign a message generated by a backend, then the backend will consider you the owner of that public address. Therefore, we can build a signature authentication mechanism with a user’s public address as their username.
If you don’t already have the Serverless CLI and the AWS CLI installed, make sure to set both of these set up by following the instructions here before we get started 💪
Resources Service
To start, let’s create our Cognito User Pool. Personally, I prefer to use the Serverless framework to deploy resources vs using the AWS Console.
We’ll create our Pool in a separate service from our triggers because our Serverless template needs to know about our Pool ahead of time in order to attach our triggers (more on this later).
First let’s create our project folder, then create our first Serverless template inside resources.yml
mkdir web3-login
cd web3-login
touch resources.yml
Then let’s edit our resources.yml
to look like this:
This will create 2 resources for us. The first being our Cognito User Pool that will store all of our users and respond to auth events via triggers we’ll set up in the next section. The second resource is our Cognito User Pool Web Client. We’ll use this in our front end React app to communicate with our Pool.
When you’re ready, run this command to create the Pool resources:
serverless deploy --config resources.yml
Ok on to the next service! 🚀
Cognito Triggers Service
Cognito has a built in way to respond to authentication events via Lambda functions called ‘triggers’. The Serverless template below creates 4 lambda functions to respond to the following events: DefineAuthChallenge
, CreateAuthChallenge
, VerifyAuthChallenge
, and PreSignUp
. If you’d like to read more on when these triggers are called, check out the docs here.
Let’s create a new file called triggers.yml
in our project folder:
touch triggers.yml
We’ll walk though this template later but for now, edit your triggers.yml
to look like this:
Here we’re defining our 4 cognito triggers. When we deploy this service later, it will create 4 lambda functions, and attach them to our Pool for us.
Now let’s create these 4 trigger files in our project folder:
touch defineAuthChallenge.js createAuthChallenge.js verifyAuthChallenge.js preSignUp.js
Finally, let’s install a few npm modules to help us with signature verification:
npm init --yes
npm install web3 ethereumjs-util
Your folder contents should now look like this:
web3-login/
node_modules/
resources.yml
triggers.yml
defineAuthChallenge.js
createAuthChallenge.js
verifyAuthChallenge.js
preSignUp.js
package.json
Trigger #1
Lets dive in to our first trigger: defineAuthChallenge.js
defineAuthChallenge.js
will be triggered when our Pool receives a sign in request from our web client. On line 4, we’ll check to verify that this a valid sign in attempt type, `CUSTOM_CHALLENGE` , and not some other form of authentication like username/password or a social login. We only want to support signature login!
Trigger #2
Alright time for our second trigger: createAuthChallenge.js
createAuthChallenge.js
will be triggered when Cognito doesn’t have an auth challenge created yet for a sign in attempt. In this function, we’ll first need to verify that we have a user in this pool, and if not, return an error back to the client (more on this later). Lines 11 and 12 start the logic of signature verification. Essentially, we’ll need to create a message that our client will sign.
To prevent the user from logging in again with the same signature (in case it gets compromised), we make sure that the next time the same user wants to log in, they need to sign a new nonce. This is achieved by generating another random nonce
for this user and attaching it to the session so we can verify it in the next trigger.
Note: Any data in publicChallengeParameters
will be passed down to our client, and any data in privateChallengeParameters
will only be available to our backend triggers.
Trigger #3
On to the third trigger! My personal favorite: verifyAuthChallenge.js
This is the fun part. Cognito receives a the challenge answer input from our client which contains signature
from our web3 provider. Now we need to verify if the address
(the userName
on our Cognito user), has signed the correct nonce
.
Most providers will prepend \u0019Ethereum Signed Message:\n
and the message length to every personal_sign
request, so we’ll need to do the same here before we hash our message
.
The next block is the verification itself. There is some cryptography involved. If you feel adventurous I recommend you reading more about elliptic curve signatures.
To summarize this block, what it does is, given our privateChallengeParamters.message
(containing the nonce
) and our signature
, the ecrecover
function outputs the public address used to sign the privateChallengeParamters.message
. If it matches our address
on the Cognito user, then the user who made the request successfully proved their ownership of address
. We consider them authenticated 🔥
Trigger #4
And last but not least, our last trigger: preSignUp.js
This trigger is called immediately before a new cognito user is registered. This allows us to validate any info we need and even reject the sign up if we wanted to for any reason. In our case, we just want to auto confirm every user who signs up so that they can be issued JWTs after their signature is verified.
After you’ve got these files ready, lets run another deployment, but against our triggers service this time:
serverless deploy --config triggers.yml
And that’s our backend! Before we move to the front end, we need to grab a few IDs from our Serverless stack. In your project folder, run this command:
serverless info --config resources.yml --verbose
You’ll see something like this as a result:
In the “Stack Outputs” section, copy down the UserPoolId
and the UserPoolWebClient
. We’ll be using both of those IDs in our front end ✍️
Front End
Check out the demo here: walletdemo.barrickapps.com
Enter your UserPoolId
, UserPoolWebClient
, and Region
where your Pool is deployed.
Next, you should see a “Connect Wallet” which will prompt the MetaMask dialog to pop and ask to connect a wallet, then sign your nonce.
Once you sign, Cognito will run through your triggers, verify the MetaMask signature, and return back an authenticated Cognito user from your pool!
If you made it this far, congrats on building a serverless wallet login flow! 🎉
Thanks for reading! If you’ve got any feedback or questions feel free to reach out to me on twitter: @DavBarrick
The code for this demo and triggers above are here: https://github.com/DavidBarrick/cognito-wallet-login