Securely connecting Web3 applications to Web2 backends presents unique challenges, especially when handling user authentication. Without proper safeguards, systems are vulnerable to spoofing and unauthorized access. In this article, we’ll explore how to ensure secure and verifiable authentication by implementing a secure challenge-response mechanism.
Technical Overview
Loading graph...
Server Implementation
TIPI’ll be using tRPC in these backend code snippets, but the same can be achieved in any other server framework such as Express or NextJS.
Let’s start off by creating a challenge
and sending it back to the requestor.
import { randomBytes } from "crypto";
export interface IChallenge {
address: string;
challengeHex: string;
createdAt: string;
}
// in prod you'd use redis or memcached for this,
// though in-memory is perfectly fine for most cases.
export const ChallengeStore = new Map<string, IChallenge>();
// utility for generating the messages
export const getChallengeMsg = (address: string, challenge: string) =>
`Sign this message to verify your Ethereum address ${address}: ${challenge}`;
// server router definition and implementation
export const appRouter = router({
challenge: router({
create: publicProcedure
.meta({
openapi: {
method: "POST",
path: "/challenge/create",
},
})
.input(z.object({ address: z.string().nonempty() }))
.output(z.object({ challengeMessage: z.string() }))
.mutation(async (opts) => {
const { input } = opts;
// generate a random hex string
const challengeHex = randomBytes(16).toString("hex");
// generate the challenge message
const challengeMessage = getChallengeMsg(input.address, challengeHex);
// create the new challenge instance
const challenge: IChallenge = {
address: input.address,
challengeHex: challengeHex,
createdAt: new Date().toUTCString(),
};
// Persist the challenge.
ChallengeStore.set(challengeMessage, challenge);
return { challengeMessage };
}),
}),
});
Not much going on here. We’re simply exposing an endpoint in our server to which web3 users can send their address and get a random string (challenge
) in response.
Now comes the interesting part, which is proving effective ownership of the address.
TIPI’ll be using viem in this verification snippet, however the same can be achieved using ethers or web3.js
import { verifyMessage } from "viem";
export const appRouter = router({
challenge: router({
/// ...,
sign: publicProcedure
.meta({
openapi: {
method: "POST",
path: "/challenge/sign",
},
})
.input(
z.object({
challenge: z.string().nonempty(),
signature: z.string().nonempty(),
})
)
.output(z.object({ access_token: z.string(), refresh_token: z.string() }))
.mutation(async (opts) => {
const { input } = opts;
const { challenge, signature } = input;
// retrieve challenge from ChallengeStore
const challengeData = ChallengeStore.get(challenge);
if (!challengeData)
throw new TRPCError({
code: "NOT_FOUND",
message: "Challenge not found.",
});
// check if the challenge is still valid
if (
Date.now() - new Date(challengeData.createdAt).getTime() >
env.CHALLENGE_EXP_SECONDS * 1000 // 1s = 1000ms
) {
// in a prod setting (redis exp.) this would happen automatically.
ChallengeStore.delete(challenge);
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Challenge expired.",
});
}
const isValid = await verifyMessage({
address: challengeData.address as `0x${string}`,
message: challenge,
signature: signature as `0x${string}`,
});
if (!isValid)
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid Signature.",
});
const tokens = await Auth.Jwt.createTokensFor(challengeData.address);
return tokens;
}),
}),
});
Now we have a couple interesting things happening here.
First, checking the expiracy of the challenge
, which is important for both security and storage reasons. I always set this value to no more than 60 seconds, because being realistic, clicking “Sign” when the wallet pop-up opens will take you at most 5 seconds, however there are cases where you’ll want the expiracy to be higher, for example when using multisig or hardware wallets (not covered in this article).
And the second being actually verifying the challenge signature. For further technical detail on this, I suggest you to look at its implementation and tests. To make it short, everytime you sign a message in ethereum, all of its components can be decoded from the produced signature: the message (challenge
) and the signer address. the verifyMessage
function decodes both of these, extracts the encoded address and compares it against the provided address, if they match then it returns true
.
TIPCheck the integration tests
TIPBonus Track: JWT Authentication & custom env utility
import jwt from "jsonwebtoken";
import env from "./env";
export namespace Auth {
export namespace Jwt {
export const createTokensFor = async (address: string) => {
const access_token = jwt.sign({ sub: address }, env.JWT_AT_SECRET_KEY, {
expiresIn: env.JWT_AT_EXP_IN,
});
const refresh_token = jwt.sign({ sub: address }, env.JWT_RT_SECRET_KEY, {
expiresIn: env.JWT_RT_EXP_IN,
});
return { access_token, refresh_token };
};
export const decodeAndVerify = async (
tokenType: "access" | "refresh",
token: string
) => {
let jwtSecretKey = tokenType === "access" ? env.JWT_AT_SECRET_KEY : env.JWT_RT_SECRET_KEY;
return jwt.verify(token, jwtSecretKey).sub;
};
}
}
// env.ts
import { z } from "zod";
// Define the schema as an object with all of the env
// variables and their types
const envSchema = z.object({
PORT: z.coerce.number().min(1000).default(3000),
NODE_ENV: z
.union([z.literal("dev"), z.literal("test"), z.literal("prod")])
.default("dev"),
CHALLENGE_EXP_SECONDS: z.coerce
.number()
.positive()
.default(60)
.refine((v) => {
if (process.env.NODE_ENV === "test") return 0.05;
return v;
}),
JWT_AT_SECRET_KEY: z.string().nonempty().min(16).default("verysecretkey!!!"),
JWT_RT_SECRET_KEY: z.string().nonempty().min(16).default("verysecretkey!!!"),
JWT_AT_EXP_IN: z.string().nonempty().default("1h"),
JWT_RT_EXP_IN: z.string().nonempty().default("24h"),
});
// Validate `process.env` against our schema
// and return the result
const env = envSchema.parse(process.env);
// Export the result so we can use it in the project
export default env;
Client Implementation
I was able to quickly test this backend by using one of the starter wagmi templates. You can do the same by running this command:
npx create wagmi@latest
On this starter template, I simply modified the src/App.tsx file to add a “Request Web2 Session” button that triggered the backend endpoints we went through in the previous section.
import { useCallback } from "react";
import { useAccount, useConnect, useDisconnect, useSignMessage } from "wagmi";
function App() {
const account = useAccount();
const { signMessageAsync } = useSignMessage();
const { connectors, connect, status, error } = useConnect();
const { disconnect } = useDisconnect();
const requestWeb2Session = useCallback(async () => {
const challengeRes = await fetch(
"http://localhost:3000/api/challenge/create",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ address: account.address }),
}
);
const challengeData = await challengeRes.json();
const signature = await signMessageAsync({
account: account.address,
message: challengeData.challengeMessage,
});
const authRes = await fetch("http://localhost:3000/api/challenge/sign", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
challenge: challengeData.challengeMessage,
signature,
}),
});
const authData = await authRes.json();
console.log(authData);
window.alert(JSON.stringify(authData, null, 2));
}, [account, signMessageAsync]);
return (
<>
<div>
<h2>Account</h2>
<div>
status: {account.status}
<br />
addresses: {JSON.stringify(account.addresses)}
<br />
chainId: {account.chainId}
</div>
{account.status === "connected" && (
<div>
<button type="button" onClick={() => requestWeb2Session()}>
Request Web2 Session
</button>
<hr />
<button type="button" onClick={() => disconnect()}>
Disconnect
</button>
</div>
)}
</div>
<div>
<h2>Connect</h2>
{connectors.map((connector) => (
<button
key={connector.uid}
onClick={() => connect({ connector })}
type="button"
>
{connector.name}
</button>
))}
<div>{status}</div>
<div>{error?.message}</div>
</div>
</>
);
}
Demo