Authenticate Users in Your DApp with Sign in with Ethereum: A Step-by-Step Guide
How to Connect Your DApp with a Backend Using Sign in with Ethereum
In an ideal scenario, A Dapp should not have any centralized backend server, everything should be on-chain.
Then why Backend?
- With a fully on-chain approach, we need to (at least right now) compromise on user experience
- Not everything needs to be on a chain, it just doesn’t make sense. Wouldn’t will it be great if we can make our apps sufficiently decentralized?
There is a great Article By Varun Srinivasan on Sufficient Decentralization which everyone should give read - Better scalability: A backend can help a Dapp scale more easily by handling tasks that might otherwise slow down the blockchain or consume a large amount of gas.
- Off-chain computation: Some tasks, such as image or video processing, can be resource-intensive and may not be practical to perform on the blockchain. A backend can handle these tasks and communicate with the Dapp as needed.
For this or any reason you might need to have the backend connected to your Dapp, but how? let’s get started with it
So, what are we building Deep? 👀
A Dapp which connects to your backend maintains a session and connects with the Database.
What is SIWE (Sign-In with Ethereum)?
Sign-In with Ethereum describes how Ethereum accounts to authenticate with off-chain services by signing a standard message format parameterized by scope, session details, and security mechanisms (e.g., a nonce). This specification aims to provide a self-custodied alternative to centralized identity providers, improve interoperability across off-chain services for Ethereum-based authentication, and provide wallet vendors with a consistent machine-readable message format to achieve enhanced user experiences and consent management.
More details can be found here https://docs.login.xyz/general-information/siwe-overview/eip-4361
Here are some steps we will be following
- Connect wallet
- Sign SIWE message with the nonce generated by the backend
- Verify the submitted SIWE message and signature via the POST request
- Add validated SIWE fields to the session (via JWT, cookie, etc.)
- Store user details(eg: name) in MongoDB
- Update user information
- Maintaining sessions b/w page refreshes
Tools We are going to use:
- NextUI — Component library, of course, I hate CSS and I’m not going to write any of it
- Wagmi — React Hooks for Ethereum
- Iron Session
Clone the code from the GitHub repo, to follow through
Step1: Let’s create some API routes first
under pages/api create a file nonce.ts with the following content
pages/api/nonce.ts
import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'
import { generateNonce } from 'siwe'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req
switch (method) {
case 'GET':
req.session.nonce = generateNonce()
await req.session.save()
res.setHeader('Content-Type', 'text/plain')
res.send(req.session.nonce)
break
default:
res.setHeader('Allow', ['GET'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}
export default withIronSessionApiRoute(handler, sessionOptions)
Next, add an API route to verify a SIWE message and make the user session.
pages/api/verify.ts
import { handleLoginOrSignup } from 'core/services/user.service'
import { withIronSessionApiRoute } from 'iron-session/next'
import dbConnect from 'lib/dbConnect'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'
import { SiweMessage } from 'siwe'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req
switch (method) {
case 'POST':
try {
await dbConnect();
const { message, signature } = req.body
const siweMessage = new SiweMessage(message)
const fields = await siweMessage.validate(signature)
if (fields.nonce !== req.session.nonce) {
return res.status(422).json({ message: 'Invalid nonce.' })
}
req.session.siwe = fields;
// maintaining users details in MongoDB from below line
// we will get backto this later ignore for now
const user = await handleLoginOrSignup(fields.address);
req.session.user = user;
await req.session.save()
res.json({ ok: true })
} catch (_error) {
console.log('error -> verify', _error)
res.json({ ok: false, error: _error })
}
break
default:
res.setHeader('Allow', ['POST'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}
export default withIronSessionApiRoute(handler, sessionOptions)
const { message, signature } = req.body
const siweMessage = new SiweMessage(message)
const fields = await siweMessage.validate(signature)
We will be passing a message and signature done by the user's wallet from the Frontend, on the API side we are verifying it and the nonce, nonce will prevent the replay attacks.
our iron session config file is going to look something like
// this file is a wrapper with defaults to be used in both API routes and `getServerSideProps` functions
import type { IronSessionOptions } from "iron-session";
import { IUser } from "models/User";
export const sessionOptions: IronSessionOptions = {
password: 'passowrd_cookie',
cookieName: "iron-session/examples/next.js",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
},
};
// This is where we specify the typings of req.session.*
declare module "iron-session" {
interface IronSessionData {
siwe: any;
nonce: any;
user: IUser | undefined; // users details from DB
}
}
Now, let’s create an API for checking user’s sessions and returning users details if a user is already logged in pages/api/me.ts
Note: findUserByAddress
is a helper method that is getting user's details from MongoDB, you can check out the code from GitHub, to explore this helper method
import { findUserByAddress } from 'core/services/user.service'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req
switch (method) {
case 'GET':
const address = req.session.siwe?.address
if (address) {
const user = await findUserByAddress(address)
res.json({ address: req.session.siwe?.address, user })
return
}
res.status(StatusCodes.UNAUTHORIZED).json({
message: ReasonPhrases.UNAUTHORIZED,
})
break
default:
res.setHeader('Allow', ['GET'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}
export default withIronSessionApiRoute(handler, sessionOptions)
And lastly logout
route, where we are destroying the session
import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req
switch (method) {
case 'POST':
await req.session.destroy()
res.send({ ok: true })
break
default:
res.setHeader('Allow', ['POST'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}
export default withIronSessionApiRoute(handler, sessionOptions)
Now, let’s create a state file for maintaining all user data and related methods we are going to use context API for this
import { IUser } from 'models/User'
import React, { createContext, useContext, useState } from 'react'
import { SiweMessage } from 'siwe'
import { Connector, useAccount, useConnect } from 'wagmi'
import axios from 'axios'
import { useRouter } from 'next/router'
import { toast } from 'react-toastify'
export interface IUserState {
user: IUser | undefined
loadingUser: boolean
setUser: React.Dispatch<React.SetStateAction<undefined>>
handleSignOut: () => void
handleSignIn: (connector: Connector) => Promise<void>
}
const UserContext = createContext<IUserState>({
user: undefined,
setUser: () => {},
loadingUser: false,
handleSignOut: () => {},
handleSignIn: async () => {},
})
export function UserState({ children }: { children: JSX.Element }) {
const router = useRouter()
const [user, setUser] = useState(undefined)
const [, connect] = useConnect()
const [loadingUser, setLoadingUser] = useState(false)
const [, disconnect] = useAccount({
fetchEns: true,
})
const handleSignOut = async () => {
disconnect()
await axios.post('/api/logout')
setUser(undefined)
router.replace('/')
}
const handleSignIn = async (connector: Connector) => {
try {
const res = await connect(connector) // connect from useConnect
if (!res.data) throw res.error ?? new Error('Something went wrong')
setLoadingUser(true)
const nonceRes = await axios('/api/nonce')
const message = new SiweMessage({
domain: window.location.host,
address: res.data.account,
statement: 'Sign in with Ethereum to the app.',
uri: window.location.origin,
version: '1',
chainId: res.data.chain?.id,
nonce: nonceRes.data,
})
const signer = await connector.getSigner()
const signature = await signer.signMessage(message.prepareMessage())
// console.log('message', message, { signature })
await axios.post('/api/verify', {
message,
signature,
})
const me = await axios('/api/me')
setUser(me.data.user)
// It worked! User is signed in with Ethereum
} catch (error) {
// Do something with the error
toast.error('Something went wrong!')
handleSignOut()
console.log('error', error)
} finally {
setLoadingUser(false)
}
}
return (
<UserContext.Provider
value={{ user, setUser, handleSignOut, handleSignIn, loadingUser }}
>
{children}
</UserContext.Provider>
)
}
export function useUserContext() {
return useContext(UserContext)
}
Breaking down handleSignIn
method
const res = await connect(connector) // connect from useConnect
if (!res.data) throw res.error ?? new Error('Something went wrong')
we are connected to the wallet here
Once the user is connected we generate a random nonce using the API we created earlier
const nonceRes = await axios('/api/nonce')
const message = new SiweMessage({
domain: window.location.host,
address: res.data.account, // users waller address
statement: 'Sign in with Ethereum to the app.',
uri: window.location.origin,
version: '1',
chainId: res.data.chain?.id,
nonce: nonceRes.data,
})
const signer = await connector.getSigner()
const signature = await signer.signMessage(message.prepareMessage())
then we ask the user to sign a message, using SiweMessage
constructor exposed by SIWE and creates a signature
await axios.post('/api/verify', {
message,
signature,
})
const me = await axios('/api/me')
setUser(me.data.user)
verifying users, and getting logged in user’s data using me
API we created earlier, then set the user's data to state
Now let’s make the API for updating user details in the Database,
import withAuth from 'core/middleware/withAuth'
import { findUserById, updateUser } from 'core/services/user.service'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { isValidObjectId } from 'mongoose'
import { NextApiRequest, NextApiResponse } from 'next'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method, query } = req
const queryId = query.id;
if (!queryId || !isValidObjectId(queryId)) {
res.status(StatusCodes.BAD_REQUEST).json({
message: "Valid id is required"
})
return;
}
switch (method) {
case 'PUT':
return await handlePatchUser();
default:
res.setHeader('Allow', ['PUT'])
res.status(405).end(`Method ${method} Not Allowed`)
};
async function handlePatchUser() {
if (req.session.user?._id !== queryId) {
res.status(StatusCodes.FORBIDDEN).json({
message: ReasonPhrases.FORBIDDEN
});
return;
}
const user = await updateUser(queryId, req.body);
if (!user) {
res.status(StatusCodes.BAD_REQUEST).json({
message: "User not found with requested id"
})
}
res.json({
user
})
}
}
export default withIronSessionApiRoute(withAuth(handler), sessionOptions)
Notice we are using withAuth
here it is a middleware we have created, so only authorized users can access our API routes Link to code
Now let’s create a profile in pages/profile.ts
import type { NextPage } from 'next'
import Head from 'next/head'
import { BaseLayout } from 'components/ui/Layout/BaseLayout'
import { ComponentWithLayout } from '../_app'
import { useFormik } from 'formik'
import axios from 'axios'
import { useUserContext } from 'core/state/user.state'
import { useState } from 'react'
import { Button, Loading } from '@nextui-org/react'
import { toast } from 'react-toastify'
const Profile: NextPage = () => {
const { user } = useUserContext()
const [loading, setLoading] = useState(false)
const formik = useFormik({
enableReinitialize: true,
initialValues: {
...user,
},
onSubmit: async (values) => {
try {
setLoading(true)
await axios.put(`/api/users/${user?._id}`, values)
toast.success('Data saved successfully')
} catch (error: any) {
toast.error(error.message)
} finally {
setLoading(false)
}
},
})
return (
<div className="flex flex-col items-center justify-center py-2">
<Head>
<title>Profile</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<section className="bg-blueGray-100 rounded-b-10xl">
<div className="container mx-auto px-4">
<div className="-mx-4 flex flex-wrap">
<div className="w-full px-4">
<div className="mx-auto max-w-xl rounded-xl bg-white py-14 px-8 md:px-20 md:pt-16 md:pb-20">
<h3 className="font-heading mb-12 text-4xl font-medium">
Profile Details
</h3>
<input
className="placeholder-darkBlueGray-400 mb-5 w-full rounded-xl border px-12 py-5 text-xl focus:bottom-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
type="text"
placeholder="Your Name"
name="name"
onChange={formik.handleChange}
value={formik.values.name}
/>
<div className="text-right">
<Button
clickable={!loading}
color="primary"
className="inline-block w-full text-center text-xl font-medium tracking-tighter md:w-auto"
onClick={formik.submitForm}
size="lg"
icon={
loading && (
<Loading type="spinner" color="white" size="md" />
)
}
>
Save
</Button>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}
export default Profile
;(Profile as ComponentWithLayout).Layout = BaseLayout
And that’s a wrap,
Follow me on Medium for more such content on web3, and Full stack development in general.
Connect with me on Twitter: @pateldeep_eth, Linkedin