Sign-In with Ethereum
Sign-In with Ethereum is an authentication standard (EIP-4361) that enables secure communication between a frontend and backend. SIWE is a powerful method for creating user sessions based on a wallet connection, and much more!
The example below builds on the Connect Wallet and Sign Message examples. Try it out before moving on.
Pretty cool, right?! You can refresh the window or disconnect your wallet, and you are still securely logged in.
Overview
Implementing SIWE only takes four steps:
- Connect wallet
- Sign SIWE message with nonce generated by backend
- Verify submitted SIWE message and signature via POST request
- Add validated SIWE fields to session (via JWT, cookie, etc.)
This guide uses Next.js API
Routes for the backend and
iron-session
to secure the user
session, but you can also use other backend frameworks and storage methods.
Prerequisites
Install siwe
and iron-session
with your package manager of choice:
npm install siwe iron-session
iron-session
TypeScript Set Up
In order for TypeScript to work properly with iron-session
and siwe
, you need to add a couple properties to the IronSessionData
interface. Add the following to types/iron-session/index.d.ts
.
import 'iron-session'
import { SiweMessage } from 'siwe'
declare module 'iron-session' {
interface IronSessionData {
nonce?: string
siwe?: SiweMessage
}
}
Then, update your tsconfig.json
to include the custom types directory:
{
"compilerOptions": {
// ...
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "types/**/*.d.ts"],
"exclude": ["node_modules"]
}
Step 1: Connect Wallet
Follow the Connect Wallet guide to get this set up.
Step 2: Add API routes
First, create an API route for generating a random nonce. This is used to identify the session and prevent against replay attacks.
import { withIronSessionApiRoute } from 'iron-session/next'
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, ironOptions)
Next, add an API route for verifying a SIWE message and creating the user session.
import { withIronSessionApiRoute } from 'iron-session/next'
import { NextApiRequest, NextApiResponse } from 'next'
import { SiweMessage } from 'siwe'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req
switch (method) {
case 'POST':
try {
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
await req.session.save()
res.json({ ok: true })
} catch (_error) {
res.json({ ok: false })
}
break
default:
res.setHeader('Allow', ['POST'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}
export default withIronSessionApiRoute(handler, ironOptions)
ironOptions
should look something like this:
{
cookieName: 'siwe',
password: 'complex_password_at_least_32_characters_long',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
},
}
Finally, add two simple API routes for retrieving the signed-in user:
import { withIronSessionApiRoute } from 'iron-session/next'
import { NextApiRequest, NextApiResponse } from 'next'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req
switch (method) {
case 'GET':
res.send({ address: req.session.siwe?.address })
break
default:
res.setHeader('Allow', ['GET'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}
export default withIronSessionApiRoute(handler, ironOptions)
And logging out:
import { withIronSessionApiRoute } from 'iron-session/next'
import { NextApiRequest, NextApiResponse } from 'next'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req
switch (method) {
case 'GET':
req.session.destroy()
res.send({ ok: true })
break
default:
res.setHeader('Allow', ['GET'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}
export default withIronSessionApiRoute(handler, ironOptions)
Step 3: Sign & Verify Message
Now that the connect wallet logic and API routes are set up, we can sign in the user! We'll create a new SiweMessage
and sign it using the useSignMessage
hook. We can also add a log out button and a side effect for fetching the logged in user when the page loads or window gains focus.
import * as React from 'react'
import { useAccount, useNetwork, useSignMessage } from 'wagmi'
import { SiweMessage } from 'siwe'
function SignInButton({
onSuccess,
onError,
}: {
onSuccess: (args: { address: string }) => void
onError: (args: { error: Error }) => void
}) {
const [state, setState] = React.useState<{
loading?: boolean
nonce?: string
}>({})
const fetchNonce = async () => {
try {
const nonceRes = await fetch('/api/nonce')
const nonce = await nonceRes.text()
setState((x) => ({ ...x, nonce }))
} catch (error) {
setState((x) => ({ ...x, error: error as Error }))
}
}
// Pre-fetch random nonce when button is rendered
// to ensure deep linking works for WalletConnect
// users on iOS when signing the SIWE message
React.useEffect(() => {
fetchNonce()
}, [])
const { address } = useAccount()
const { chain: activeChain } = useNetwork()
const { signMessageAsync } = useSignMessage()
const signIn = async () => {
try {
const chainId = activeChain?.id
if (!address || !chainId) return
setState((x) => ({ ...x, loading: true }))
// Create SIWE message with pre-fetched nonce and sign with wallet
const message = new SiweMessage({
domain: window.location.host,
address,
statement: 'Sign in with Ethereum to the app.',
uri: window.location.origin,
version: '1',
chainId,
nonce: state.nonce,
})
const signature = await signMessageAsync({
message: message.prepareMessage(),
})
// Verify signature
const verifyRes = await fetch('/api/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message, signature }),
})
if (!verifyRes.ok) throw new Error('Error verifying message')
setState((x) => ({ ...x, loading: false }))
onSuccess({ address })
} catch (error) {
setState((x) => ({ ...x, loading: false, nonce: undefined }))
onError({ error: error as Error })
fetchNonce()
}
}
return (
<button disabled={!state.nonce || state.loading} onClick={signIn}>
Sign-In with Ethereum
</button>
)
}
export function Profile() {
const { isConnected } = useAccount()
const [state, setState] = React.useState<{
address?: string
error?: Error
loading?: boolean
}>({})
// Fetch user when:
React.useEffect(() => {
const handler = async () => {
try {
const res = await fetch('/api/me')
const json = await res.json()
setState((x) => ({ ...x, address: json.address }))
} catch (_error) {}
}
// 1. page loads
handler()
// 2. window is focused (in case user logs out of another window)
window.addEventListener('focus', handler)
return () => window.removeEventListener('focus', handler)
}, [])
if (isConnected) {
return (
<div>
{/* Account content goes here */}
{state.address ? (
<div>
<div>Signed in as {state.address}</div>
<button
onClick={async () => {
await fetch('/api/logout')
setState({})
}}
>
Sign Out
</button>
</div>
) : (
<SignInButton
onSuccess={({ address }) => setState((x) => ({ ...x, address }))}
onError={({ error }) => setState((x) => ({ ...x, error }))}
/>
)}
</div>
)
}
return <div>{/* Connect wallet content goes here */}</div>
}
Wrap Up
That's it! You now have a way for users to securely sign in to an app using Ethereum wallets. You can start building rich web apps that use persistent user sessions while still letting users control their login identity (and so much more). Check out the Sign-In with Ethereum website for more info.
While it might be tempting to combine the "Connect Wallet" and "Sign In" steps into a single action, this causes issues with deep linking for WalletConnect on iOS. This is because the browser doesn't open the corresponding native app if the navigation wasn't immediately triggered by a user action.
Additional Resources
- Sign-In with Ethereum to Next.js Applications Guide for setting up SIWE with
next-auth
from the Spruce team.