Backend
In the backend (in my case it's called storage
), I've added the following endpoints:
/message-to-sign/:userAddress
-> creates a message that the user will sign with their Metamask, containing the user address and an OTP to prevent replay attacks. When the OTP is generated, we save it locally, so when login
is called, we verify the OTP for that address.
const message = composeMessage(userAddress, otp);
return res.json({ message });
/login
-> The signed message is sent over and verified. If it matches, the user is authenticated.
const message = composeMessage(userAddress, otp);
const decodedAddress = utils.verifyMessage(message, signedMessage);
if (userAddress !== decodedAddress) {
return res.status(403).send("Invalid signature");
}
// generate JWT and send back
const authToken = jwt.sign({ userAddress }, process.env.JWT_SECRET as string, { expiresIn: '2592000s' });
res.cookie('token', authToken, { httpOnly: true, secure: true });
return res.send();
Frontend
In the frontend we
- get the message to be signed
- get the user to sign it with their wallet
- send back the signed message
- if the request works, the user will be logged in
async function triggerLogin(walletAddress: string): Promise<void> {
const message = await getLoginMessage(walletAddress); // call /message-to-sign/:userAddress
const signedMessage = await signMessage(message);
await login(walletAddress, signedMessage); // call /login
}
async function signMessage(message: string): Promise<any> {
const ethers = require('ethers');
const provider = new ethers.providers.Web3Provider((window as any).ethereum);
const signer = provider.getSigner();
const signature = await signer.signMessage(message);
return signature;
}
I've taken inspiration from debank.com to create the login system.