Implement Sessions
Here we learn how to implement sessions with Express.js to add the necessary backend security.
A completed version of the updated backend can be found here (03_complete_app/backend). This example uses the browser console to print messages, so it should be actively monitored.
For additional security against replay attacks, it is not enough for the backend to generate the nonce. It must also be tied to a browser session with the end-user. In the siwe-backend directory, install the following and edit index.js:
npm install --save express-session
Update src/index.js to the following:
src/index.js
import cors from 'cors';
import express from 'express';
import Session from 'express-session';
import { generateNonce, SiweMessage } from 'siwe';
​
const app = express();
app.use(express.json());
app.use(cors({
origin: 'http://localhost:8080',
credentials: true,
}))
​
app.use(Session({
name: 'siwe-quickstart',
secret: "siwe-quickstart-secret",
resave: true,
saveUninitialized: true,
cookie: { secure: false, sameSite: true }
}));
​
app.get('/nonce', async function (req, res) {
req.session.nonce = generateNonce();
res.setHeader('Content-Type', 'text/plain');
res.status(200).send(req.session.nonce);
});
​
app.post('/verify', async function (req, res) {
try {
if (!req.body.message) {
res.status(422).json({ message: 'Expected prepareMessage object as body.' });
return;
}
​
let message = new SiweMessage(req.body.message);
const fields = await message.validate(req.body.signature);
if (fields.nonce !== req.session.nonce) {
console.log(req.session);
res.status(422).json({
message: `Invalid nonce.`,
});
return;
}
req.session.siwe = fields;
req.session.cookie.expires = new Date(fields.expirationTime);
req.session.save(() => res.status(200).end());
} catch (e) {
req.session.siwe = null;
req.session.nonce = null;
console.error(e);
switch (e) {
case ErrorTypes.EXPIRED_MESSAGE: {
req.session.save(() => res.status(440).json({ message: e.message }));
break;
}
case ErrorTypes.INVALID_SIGNATURE: {
req.session.save(() => res.status(422).json({ message: e.message }));
break;
}
default: {
req.session.save(() => res.status(500).json({ message: e.message }));
break;
}
}
}
});
​
app.get('/personal_information', function (req, res) {
if (!req.session.siwe) {
res.status(401).json({ message: 'You have to first sign_in' });
return;
}
console.log("User is authenticated!");
res.setHeader('Content-Type', 'text/plain');
res.send(`You are authenticated and your address is: ${req.session.siwe.address}`);
});
​
app.listen(3000);
This way, the session (req.session) stores the nonce for the initial validation of the message, and once that's done, more can be added. For example here we store the message's fields, so we can always reference the address of the user.
A potential extension is to resolve the ENS domain of the user and keep it in the session.
Refer to http://expressjs.com/en/resources/middleware/session.html for additional information on how to use express-session in production.
On the client-side, the flow is similar to the previous example, except we now need to send cookies with our requests for the session to work. We can add a new endpoint, personal_information, to retrieve the information from the session in place, without having to send the message and signature every time.
In the siwe-frontend directory, stop any running instances and populate src/index.js to match the following:
src/index.js
import { ethers } from 'ethers';
import { SiweMessage } from 'siwe';
​
const domain = window.location.host;
const origin = window.location.origin;
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
​
const BACKEND_ADDR = "http://localhost:3000";
async function createSiweMessage(address, statement) {
const res = await fetch(`${BACKEND_ADDR}/nonce`, {
credentials: 'include',
});
const message = new SiweMessage({
domain,
address,
statement,
uri: origin,
version: '1',
chainId: '1',
nonce: await res.text()
});
return message.prepareMessage();
}
​
function connectWallet() {
provider.send('eth_requestAccounts', [])
.catch(() => console.log('user rejected request'));
}
​
async function signInWithEthereum() {
const message = await createSiweMessage(
await signer.getAddress(),
'Sign in with Ethereum to the app.'
);
const signature = await signer.signMessage(message);
​
const res = await fetch(`${BACKEND_ADDR}/verify`, {
method: "POST",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message, signature }),
credentials: 'include'
});
console.log(await res.text());
}
​
async function getInformation() {
const res = await fetch(`${BACKEND_ADDR}/personal_information`, {
credentials: 'include',
});
console.log(await res.text());
}
​
const connectWalletBtn = document.getElementById('connectWalletBtn');
const siweBtn = document.getElementById('siweBtn');
const infoBtn = document.getElementById('infoBtn');
connectWalletBtn.onclick = connectWallet;
siweBtn.onclick = signInWithEthereum;
infoBtn.onclick = getInformation;
Update the siwe-frontend/src/index.html to match the following:
src/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SIWE Quickstart</title>
</head>
​
<body>
<div><button id="connectWalletBtn">Connect wallet</button></div>
<div><button id="siweBtn">Sign-in with Ethereum</button></div>
<div><button id="infoBtn">Get session information</button></div>
</body>
</html>
3. For this last step, you need to have both the frontend and backend running together. Start by running the backend server with the following command from the parent directory:
cd siwe-backend
node src/index.js
In a separate terminal, start the frontend by running the following command and visit the URL printed to the console:
cd siwe-frontend && npm run dev
4. Try to Sign-In with Ethereum by visiting the URL printed to the console, connecting your wallet, and signing in. You can now hit the Get session information button to receive a message similar to the following in the console:
You are authenticated and your address is:
0x6Ee9894c677EFa1c56392e5E7533DE76004C8D94
Copy link