Combine Sign-In With Ethereum With Create-T3-App
Configure SIWE wallet authentication with popular t3 stack which includes NextJS, TypeScript, Tailwind, tRPC, Prisma, and AuthJS (previously NextAuthJS)
What Is The t3 Stack?
Create T3 Stack is definitely one of the fastest ways to get up and running to build an application today. It’s a stack, similar to MEAN, MERN, MAMP, etc. The main benefit is that it comes with is the best tools in the industry to get started building for developers.
The T3 stack comes with NextJS, NextAuth, Prisma, tRPC, Tailwind, and all with TypeScript support. This is everything you need to get started in building an app from scratch and even gives you options to pick and choose which you’d like to include/exclude in your stack to optimize even further.
Give this tutorial a try and you should be able to see how easy it is to build a frontend, backend, session management, and database queries.
What Is Sign-In With Ethereum (SIWE)?
For developers in web3, Sign-In With Ethereum is a standard that let’s you verify a message by signing it with a crypto wallet, more specifically a Ethereum crypto wallet.
The benefits of this unlock how we could do native verification of messages, and essentially get at authentication. In this tutorial, I’ll be showing you how we can merge SIWE with a full application built with the t3 stack.
Requirements
Before we begin, make sure you have the latest version of these requirements installed on your computer.
- NVM or Node
v18.5.0
- npm
Integrating SIWE With T3 Stack
What we’ll be aiming for is getting the create-t3-stack working locally with the Sign-In With Ethereum functionality.
Create New create-t3-app
The first thing we’ll do is scaffold out the stack as a new project.
pnpm create t3-app@latest; # npm create t3-app@latest;
# Expected Output:
# ___ ___ ___ __ _____ ___ _____ ____ __ ___ ___
# / __| _ \ __| / \_ _| __| |_ _|__ / / \ | _ \ _ \
# | (__| / _| / /\ \| | | _| | | |_ \ / /\ \| _/ _/
# \___|_|_\___|_/‾‾\_\_| |___| |_| |___/ /_/‾‾\_\_| |_|
#
#
# ? What will your project be called? t3-siwe
# ? Will you be using TypeScript or JavaScript? TypeScript
# Good choice! Using TypeScript!
# ? Which packages would you like to enable? nextAuth, prisma, tailwind, trpc
# ? Initialize a new git repository? Yes
# Nice one! Initializing repository!
# ? Would you like us to run 'pnpm install'? Yes
# Alright. We'll install the dependencies for you!
# ? What import alias would you like configured? ~/
#
# Using: pnpm
#
# ✔ t3-siwe-wip scaffolded successfully!
# Adding boilerplate...
# ✔ Successfully setup boilerplate for nextAuth
# ✔ Successfully setup boilerplate for prisma
# ✔ Successfully setup boilerplate for tailwind
# ✔ Successfully setup boilerplate for trpc
# ✔ Successfully setup boilerplate for envVariables
#
# Installing dependencies...
# ✔ Successfully installed dependencies!
#
# Initializing Git...
# ✔ Successfully initialized and staged git
#
# Next steps:
# cd t3-siwe
# pnpm prisma db push
# pnpm dev
One of the options we selected is Prisma, so we’ll need to initiate the database. We’ll use the default db.sqlite
set int he .env
file.
cd t3-siwe;
npx prisma migrate dev;
# Expected Output:
# Environment variables loaded from .env
# Prisma schema loaded from prisma/schema.prisma
# Datasource "db": SQLite database "db.sqlite" at "file:./db.sqlite"
#
# ? Enter a name for the new migration: › create_new_example_account_session_user_verificationtoken
# Applying migration `20230408143224_create_new_example_account_session_user_verificationtoken`
#
# The following migration(s) have been created and applied from new schema changes:
#
# migrations/
# └─ 20230408143224_create_new_example_account_session_user_verificationtoken/
# └─ migration.sql
#
# Your database is now in sync with your schema.
#
# ✔ Generated Prisma Client (4.11.0 | library) to ./node_modules/.pnpm/@prisma+client@4.11.0_prisma@4.11.0/node_modules
# /@prisma/client in 611ms
In a new Terminal, let’s take a look at our new database.
# FROM: ./t3-siwe
npx prisma studio;
# Expected Output:
# Environment variables loaded from .env
# Prisma schema loaded from prisma/schema.prisma
# Prisma Studio is up on http://localhost:5555
Now that we have our database set up, let’s start the stack and see what it looks like.
# FROM: ./t3-siwe
pnpm dev;
# Expected Output:
# > t3-siwe-wip@0.1.0 dev /Users/username/path/to/t3-siwe-wip
# > next dev
#
# ready - started server on 0.0.0.0:3000, url: http://localhost:3000
You’ll notice if we click the Sign in
button, we’ll be prompted to a screen that comes with the default Sign in with Discord
.
We’re going to replace this functionality.
Adding Connect With Wallet Functionality
To start, we’re going to first establish if the user has connected to the site in order to be prompted to sign the message needed for Sign-In With Ethereum.
For this we’ll need to install some dependencies first.
# FROM: ./t3-siwe
pnpm add wagmi siwe ethers@^5;
With the dependencies, we need to configure Wagmi as provider for our entire app.
File: ./src/pages/_app.tsx
// Imports
// ========================================================
import { type AppType } from "next/app";
import { type Session } from "next-auth";
import { SessionProvider } from "next-auth/react";
import { api } from "~/utils/api";
import "~/styles/globals.css";
// SIWE Integration
import { WagmiConfig, createClient, configureChains } from "wagmi";
import { mainnet, polygon, optimism, arbitrum } from "wagmi/chains";
import { publicProvider } from "wagmi/providers/public";
// Config
// ========================================================
/**
* Configure chains supported
*/
const { provider } = configureChains(
[mainnet, polygon, optimism, arbitrum],
[publicProvider()]
);
/**
* Configure client with providers and allow for auto wallet connection
*/
const client = createClient({
autoConnect: true,
provider,
});
// App Wrapper Component
// ========================================================
const MyApp: AppType<{ session: Session | null }> = ({
Component,
pageProps: { session, ...pageProps },
}) => {
return (
<WagmiConfig client={client}>
<SessionProvider session={session}>
<Component {...pageProps} />
</SessionProvider>
</WagmiConfig>
);
};
// Exports
// ========================================================
export default api.withTRPC(MyApp);
With the configuration done, we can now modify our main home page to allow the user to connect their wallet to the website. This will show when the wallet has been connected and allows the user to also disconnect.
File: ./src/pages/index.tsx
// Imports
// ========================================================
import { type NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
// (Will add back)
// import { signIn, signOut, useSession } from "next-auth/react";
import { api } from "~/utils/api";
// SIWE Integration
import { useAccount, useConnect, useDisconnect } from "wagmi";
import { InjectedConnector } from 'wagmi/connectors/injected';
// Auth Component
// ========================================================
const AuthShowcase: React.FC = () => {
// Hooks
// (Will add back)
// const { data: sessionData } = useSession();
// const { data: secretMessage } = api.example.getSecretMessage.useQuery(
// undefined, // no input
// { enabled: sessionData?.user !== undefined },
// );
// Wagmi Hooks
const { address, isConnected } = useAccount();
const { connect } = useConnect({
connector: new InjectedConnector(),
});
const { disconnect } = useDisconnect();
// Render
return (
<div className="flex flex-col items-center justify-center gap-4">
<div className="text-center">
{address
? <p className="mb-4">
<code className="block p-4 text-white bg-black/20 rounded">{address}</code>
</p>
: null
}
<button
className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
onClick={() => !isConnected ? connect() : disconnect()}
>
{!isConnected ? 'Connect Wallet' : 'Disconnect'}
</button>
</div>
</div>
);
};
// Page Component
// ========================================================
const Home: NextPage = () => {
// Requests
const hello = api.example.hello.useQuery({ text: "from tRPC" });
// Render
return (
<>
<Head>
<title>Create T3 App</title>
<meta name="description" content="Generated by create-t3-app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16 ">
<h1 className="text-5xl font-extrabold tracking-tight text-white sm:text-[5rem]">
Create <span className="text-[hsl(280,100%,70%)]">T3</span> App
</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8">
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/usage/first-steps"
target="_blank"
>
<h3 className="text-2xl font-bold">First Steps →</h3>
<div className="text-lg">
Just the basics - Everything you need to know to set up your
database and authentication.
</div>
</Link>
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/introduction"
target="_blank"
>
<h3 className="text-2xl font-bold">Documentation →</h3>
<div className="text-lg">
Learn more about Create T3 App, the libraries it uses, and how
to deploy it.
</div>
</Link>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-2xl text-white block mb-4">
{hello.data ? hello.data.greeting : "Loading tRPC query..."}
</p>
<AuthShowcase />
</div>
</div>
</main>
</>
);
};
// Exports
// ========================================================
export default Home;
This looks like a good start until you hard refresh the site and see an error.
We’re going to implement a quick fix for this, but if you want to know more about this issue, check out my other article Understanding Hydration Errors In NextJS 13 With A Web3 Wallet Connection.
File: ./src/pages/index.tsx
// Imports
// ========================================================
import { type NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
import { useEffect, useState } from "react";
// (Will add back)
// import { signIn, signOut, useSession } from "next-auth/react";
import { api } from "~/utils/api";
// SIWE Integration
import { useAccount, useConnect, useDisconnect } from "wagmi";
import { InjectedConnector } from 'wagmi/connectors/injected';
// Auth Component
// ========================================================
const AuthShowcase: React.FC = () => {
// Hooks
// (Will add back)
// const { data: sessionData } = useSession();
// const { data: secretMessage } = api.example.getSecretMessage.useQuery(
// undefined, // no input
// { enabled: sessionData?.user !== undefined },
// );
// State
const [showConnection, setShowConnection] = useState(false);
// Wagmi Hooks
const { address, isConnected } = useAccount();
const { connect } = useConnect({
connector: new InjectedConnector(),
});
const { disconnect } = useDisconnect();
// Hooks
/**
* Handles hydration issue
* only show after the window has finished loading
*/
useEffect(() => {
setShowConnection(true);
}, []);
// Render
return (
<div className="flex flex-col items-center justify-center gap-4">
{showConnection
? <div className="text-center">
{address
? <p className="mb-4">
<code className="block p-4 text-white bg-black/20 rounded">{address}</code>
</p>
: null
}
<button
className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
onClick={() => !isConnected ? connect() : disconnect()}
>
{!isConnected ? 'Connect Wallet' : 'Disconnect'}
</button>
</div>
: null}
</div>
);
};
// Page Component
// ========================================================
// ...
SIWE Next Auth Provider Configuration
For this next part, we need to setup the correct Authentication Provider for NextAuthJS (AuthJS), but in order for things to work, we need to make sure we have a specific version of next-auth
installed, as there is currently an issue.
# FROM: ./t3-siwe
pnpm add next-auth@4.20.1;
Once we have that installed, we’re going to configure our authOptions
for the Authentication Provider. This is the main configuration for NextAuth that we’re going to leverage with its existing convention for configuration.
File: ./src/server/auth.ts
// Imports
// ========================================================
import { type GetServerSidePropsContext } from "next";
import { getServerSession, type NextAuthOptions, type DefaultSession } from "next-auth";
// (Will add back)
// import { prisma } from "~/server/db";
// SIWE Integration
import type { CtxOrReq } from "next-auth/client/_utils";
import CredentialsProvider from "next-auth/providers/credentials";
import { SiweMessage } from "siwe";
import { getCsrfToken } from "next-auth/react";
// Types
// ========================================================
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
}
// Auth Options
// ========================================================
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authOptions: (ctxReq: CtxOrReq) => NextAuthOptions = ({ req }) => ({
callbacks: {
// token.sub will refer to the id of the wallet address
session: ({ session, token }) => ({
...session,
user: {
...session.user,
id: token.sub,
},
} as Session & { user: { id: string; }}),
// OTHER CALLBACKS to take advantage of but not needed
// signIn: async (params: { // Used to control if a user is allowed to sign in
// user: User | AdapterUser
// account: Account | null
// // Not used for credentials
// profile?: Profile
// // Not user
// email?: {
// verificationRequest?: boolean
// }
// /** If Credentials provider is used, it contains the user credentials */
// credentials?: Record<string, CredentialInput>
// }) => { return true; },
// redirect: async (params: { // Used for a callback url but not used with credentials
// /** URL provided as callback URL by the client */
// url: string
// /** Default base URL of site (can be used as fallback) */
// baseUrl: string
// }) => {
// return params.baseUrl;
// },
// jwt: async ( // Callback whenever JWT created (i.e. at sign in)
// params: {
// token: JWT
// user: User | AdapterUser
// account: Account | null
// profile?: Profile
// trigger?: "signIn" | "signUp" | "update"
// /** @deprecated use `trigger === "signUp"` instead */
// isNewUser?: boolean
// session?: any
// }
// ) => {
// return params.token;
// }
},
// OTHER OPTIONS (not needed)
// secret: process.env.NEXTAUTH_SECRET, // in case you want pass this along for other functionality
// adapter: PrismaAdapter(prisma), // Not meant for type 'credentials' (used for db sessions)
// jwt: { // Custom functionlaity for jwt encoding/decoding
// encode: async ({ token, secret, maxAge }: JWTEncodeParams) => {
// return encode({
// token,
// secret,
// maxAge,
// })
// },
// decode: async ({ token, secret }: JWTDecodeParams) => {
// return decode({ token, secret })
// }
// },
// session: { // Credentials defaults to this strategy
// strategy: 'jwt',
// maxAge: 2592000,
// updateAge: 86400,
// generateSessionToken: () => 'SomeValue'
// },
// events: { // Callback events
// signIn: async (message: {
// user: User
// account: Account | null
// profile?: Profile
// isNewUser?: boolean
// }) => {},
// signOut: async (message: { session: Session; token: JWT }) => {},
// createUser: async (message: { user: User }) => {},
// updateUser: async (message: { user: User }) => {},
// linkAccount: async (message: {
// user: User | AdapterUser
// account: Account
// profile: User | AdapterUser
// }) => {},
// session: async (message: { session: Session; token: JWT }) => {}
// },
providers: [
CredentialsProvider({
// ! Don't add this
// - it will assume more than one auth provider
// - and redirect to a sign-in page meant for oauth
// - id: 'siwe',
name: "Ethereum",
type: "credentials", // default for Credentials
// Default values if it was a form
credentials: {
message: {
label: "Message",
type: "text",
placeholder: "0x0",
},
signature: {
label: "Signature",
type: "text",
placeholder: "0x0",
},
},
authorize: async (credentials) => {
try {
const siwe = new SiweMessage(JSON.parse(credentials?.message as string ?? "{}") as Partial<SiweMessage>);
const nonce = await getCsrfToken({ req });
const fields = await siwe.validate(credentials?.signature || "")
if (fields.nonce !== nonce) {
return null;
}
return {
id: fields.address
};
} catch (error) {
// Uncomment or add logging if needed
console.error({ error });
return null;
}
},
})
/**
* ...add more providers here.
*
* Most other providers require a bit more work than the Discord provider. For example, the
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
*
* @see https://next-auth.js.org/providers/github
*/
],
});
// Auth Session
// ========================================================
/**
* Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file.
*
* @see https://next-auth.js.org/configuration/nextjs
*/
export const getServerAuthSession = async (ctx: {
req: GetServerSidePropsContext["req"];
res: GetServerSidePropsContext["res"];
}) => {
// Changed from authOptions to authOption(ctx)
// This allows use to retrieve the csrf token to verify as the nonce
return getServerSession(ctx.req, ctx.res, authOptions(ctx));
};
Now that we have our authOptions
configured, we need to configure nextAuth
to work with this as it differs from what was before because we need to pass it a request.
File: ./src/pages/api/auth/[...nextauth].ts
// Imports
// ========================================================
import { NextApiRequest, NextApiResponse } from "next";
import NextAuth from "next-auth";
import { authOptions } from "~/server/auth";
// Auth
// ========================================================
const Auth = (req: NextApiRequest, res: NextApiResponse) => {
const authOpts = authOptions({ req });
return NextAuth(req, res, authOpts);
};
// Exports
// ========================================================
export default Auth;
We have our NextAuth configured, except what you’ll notice is if we go to /api/auth/signin
we have a configured Sign in with Ethereum button, but this won’t work with the app, because this is configured to work as a form submission, and the wallet configuration works different.
To fix this, we’re just going to account for this by catch if this is the signin page and removing the Credentials Auth Provider.
File: ./src/pages/api/auth/[...nextauth].ts
// Imports
// ========================================================
import { NextApiRequest, NextApiResponse } from "next";
import NextAuth from "next-auth";
import { authOptions } from "~/server/auth";
// Auth
// ========================================================
const Auth = (req: NextApiRequest, res: NextApiResponse) => {
const authOpts = authOptions({ req });
const isDefaultSigninPage = req.method === "GET" && req?.query?.nextauth?.includes("signin");
// Hide Sign-In with Ethereum from default sign page
if (isDefaultSigninPage) {
// Removes from the authOptions.providers array
authOpts.providers.pop();
}
return NextAuth(req, res, authOpts);
};
// Exports
// ========================================================
export default Auth;
There is probably a more elegant solution for this, but ideally we just discourage the user from going to this link for any sort of authentication flow.
SIWE Authentication & Session Verification
Now that we have the configuration set up, we just need to alter our main home page to allow the user to sign a message which will be verified on our backend and create a session.
File: ./src/pages/index.tsx
// Imports
// ========================================================
import { type NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
import { useEffect, useState } from "react";
import { getCsrfToken, signIn, signOut, useSession } from "next-auth/react";
import { api } from "~/utils/api";
// SIWE Integration
import { SiweMessage } from "siwe";
import { useAccount, useConnect, useDisconnect, useSignMessage, useNetwork } from "wagmi";
import { InjectedConnector } from 'wagmi/connectors/injected';
// Auth Component
// ========================================================
const AuthShowcase: React.FC = () => {
// Hooks
const { data: sessionData } = useSession();
const { data: secretMessage } = api.example.getSecretMessage.useQuery(
undefined, // no input
{ enabled: sessionData?.user !== undefined },
);
// State
const [showConnection, setShowConnection] = useState(false);
// Wagmi Hooks
const { signMessageAsync } = useSignMessage();
const { address, isConnected } = useAccount();
const { connect } = useConnect({
connector: new InjectedConnector(),
});
const { disconnect } = useDisconnect();
const { chain } = useNetwork();
// Functions
/**
* Attempts SIWE and establish session
*/
const onClickSignIn = async () => {
try {
const message = new SiweMessage({
domain: window.location.host,
address: address,
statement: "Sign in with Ethereum to the app.",
uri: window.location.origin,
version: "1",
chainId: chain?.id,
// nonce is used from CSRF token
nonce: await getCsrfToken(),
})
const signature = await signMessageAsync({
message: message.prepareMessage(),
})
signIn("credentials", {
message: JSON.stringify(message),
redirect: false,
signature,
})
} catch (error) {
window.alert(error);
}
};
/**
* Sign user out
*/
const onClickSignOut = async () => {
await signOut();
};
// Hooks
/**
* Handles hydration issue
* only show after the window has finished loading
*/
useEffect(() => {
setShowConnection(true);
}, []);
// Render
return (
<div className="flex flex-col items-center justify-center gap-4">
{sessionData
? <div className="mb-4 text-center">
{sessionData ? <div className="mb-4">
<label className="block text-white/80 mb-2">Logged in as</label>
<code className="block p-4 text-white bg-black/20 rounded">{JSON.stringify(sessionData)}</code>
</div>: null}
{secretMessage ? <p className="mb-4">
<label className="block text-white/80 mb-2">Secret Message</label>
<code className="block p-4 text-white bg-black/20 rounded">{secretMessage}</code>
</p>: null}
<button
className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
onClick={onClickSignOut as () => void}
>
Sign Out
</button>
</div>
: showConnection
? <div className="mb-4">
{isConnected
? <button
className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
onClick={onClickSignIn as () => void}
>
Sign In
</button>
: null
}
</div>
: null
}
{showConnection
? <div className="text-center">
{address
? <p className="mb-4">
<code className="block p-4 text-white bg-black/20 rounded">{address}</code>
</p>
: null
}
<button
className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
onClick={() => !isConnected ? connect() : disconnect()}
>
{!isConnected ? 'Connect Wallet' : 'Disconnect'}
</button>
</div>
: null}
</div>
);
};
// Page Component
// ========================================================
// ...
If we try the app, we can see we can connect our wallet, get a prompt for SIWE, and then successfully sign in to see the user session data.
There are just two issues. If we refresh the page, we’ll see that our session is gone, and even when we are signed in, we aren’t seeing our secretMessage
which shows when there is session data established.
To fix this, we just need to modify our .env
file to add a NEXTAUTH_SECRET
.
File: ./.env
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DATABASE_URL="file:./db.sqlite"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
NEXTAUTH_SECRET="A-REALLY-LONG-SECRET-PASSWORD-32"
NEXTAUTH_URL="http://localhost:3000"
# Next Auth Discord Provider
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""
If we restart our server, and go through the authentication process, we should the secret message now. We should also see our session persist if we refresh the page as well.
If you’re happy with establishing a session with Sign-In With Ethereum, then we got everything covered, but I’d like to take it a step further and get things working on a protected page and configuring our database to store our user’s data.
Modifying Our Database For SIWE
The next thing I want to do is modify the templated database to allow for a wallet address.
If we look at our schema.prisma
file we’ll notice we’re not really use the tables for VerificationToken, Session, and Example. These are there mainly to work with database sessions, which we aren’t using.
I’m going to remove some tables, rewrite the Example as a new Todo table, and just add a new address field for the User table.
File: ./prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
// NOTE: When using postgresql, mysql or sqlserver, uncomment the @db.Text annotations in model Account below
// Further reading:
// https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
url = env("DATABASE_URL")
}
// Revised Example table
model Todo {
id String @id @default(cuid())
task String
userId String
completed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// Necessary for Next auth
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? // @db.Text
access_token String? // @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? // @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model User {
id String @id @default(cuid())
name String?
// New address
address String? @unique
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
todos Todo[]
}
With that modification done, let’s run a migration.
# FROM: ./t3-siwe
npx prisma migrate dev;
# Expected Output:
# ✔ Are you sure you want create and apply this migration? … yes
# ✔ Enter a name for the new migration: … create_siwe_structure
# Applying migration `20230409205305_create_siwe_structure`
#
# The following migration(s) have been created and applied from new schema changes:
#
# migrations/
# └─ 20230409205305_create_siwe_structure/
# └─ migration.sql
#
# Your database is now in sync with your schema.
#
# ✔ Generated Prisma Client (4.11.0 | library) to
# ./node_modules/.pnpm/@prisma+client@4.11.0_prisma@4.11
# .0/node_modules/@prisma/client in 71ms
Let’s also check on our prisma studio to see the changes.
# FROM: ./t3-siwe
npx prisma studio;
# Expected Output:
# Environment variables loaded from .env
# Prisma schema loaded from prisma/schema.prisma
# Prisma Studio is up on http://localhost:5555
New User & Account Creation On NextAuth Authentication
Now that we have the database configured, let’s get our nextOptions configured so that the callback will create a new user if needed, or retrieve an existing user. For the changes, see the authorize
section along side adding back the prisma
import at the top.
File: ./src/server/auth.ts
// Imports
// ========================================================
import { type GetServerSidePropsContext } from "next";
import { getServerSession, type NextAuthOptions, type DefaultSession } from "next-auth";
import { prisma } from "~/server/db";
// SIWE Integration
import type { CtxOrReq } from "next-auth/client/_utils";
import CredentialsProvider from "next-auth/providers/credentials";
import { SiweMessage } from "siwe";
import { getCsrfToken } from "next-auth/react";
// Types
// ========================================================
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
}
// Auth Options
// ========================================================
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authOptions: (ctxReq: CtxOrReq) => NextAuthOptions = ({ req }) => ({
callbacks: {
// token.sub will refer to the id of the wallet address
session: ({ session, token }) => ({
...session,
user: {
...session.user,
id: token.sub,
},
} as Session & { user: { id: string; }}),
// OTHER CALLBACKS to take advantage of but not needed
// signIn: async (params: { // Used to control if a user is allowed to sign in
// user: User | AdapterUser
// account: Account | null
// // Not used for credentials
// profile?: Profile
// // Not user
// email?: {
// verificationRequest?: boolean
// }
// /** If Credentials provider is used, it contains the user credentials */
// credentials?: Record<string, CredentialInput>
// }) => { return true; },
// redirect: async (params: { // Used for a callback url but not used with credentials
// /** URL provided as callback URL by the client */
// url: string
// /** Default base URL of site (can be used as fallback) */
// baseUrl: string
// }) => {
// return params.baseUrl;
// },
// jwt: async ( // Callback whenever JWT created (i.e. at sign in)
// params: {
// token: JWT
// user: User | AdapterUser
// account: Account | null
// profile?: Profile
// trigger?: "signIn" | "signUp" | "update"
// /** @deprecated use `trigger === "signUp"` instead */
// isNewUser?: boolean
// session?: any
// }
// ) => {
// return params.token;
// }
},
// OTHER OPTIONS (not needed)
// secret: process.env.NEXTAUTH_SECRET, // in case you want pass this along for other functionality
// adapter: PrismaAdapter(prisma), // Not meant for type 'credentials' (used for db sessions)
// jwt: { // Custom functionlaity for jwt encoding/decoding
// encode: async ({ token, secret, maxAge }: JWTEncodeParams) => {
// return encode({
// token,
// secret,
// maxAge,
// })
// },
// decode: async ({ token, secret }: JWTDecodeParams) => {
// return decode({ token, secret })
// }
// },
// session: { // Credentials defaults to this strategy
// strategy: 'jwt',
// maxAge: 2592000,
// updateAge: 86400,
// generateSessionToken: () => 'SomeValue'
// },
// events: { // Callback events
// signIn: async (message: {
// user: User
// account: Account | null
// profile?: Profile
// isNewUser?: boolean
// }) => {},
// signOut: async (message: { session: Session; token: JWT }) => {},
// createUser: async (message: { user: User }) => {},
// updateUser: async (message: { user: User }) => {},
// linkAccount: async (message: {
// user: User | AdapterUser
// account: Account
// profile: User | AdapterUser
// }) => {},
// session: async (message: { session: Session; token: JWT }) => {}
// },
providers: [
CredentialsProvider({
// ! Don't add this
// - it will assume more than one auth provider
// - and redirect to a sign-in page meant for oauth
// - id: 'siwe',
name: "Ethereum",
type: "credentials", // default for Credentials
// Default values if it was a form
credentials: {
message: {
label: "Message",
type: "text",
placeholder: "0x0",
},
signature: {
label: "Signature",
type: "text",
placeholder: "0x0",
},
},
authorize: async (credentials) => {
try {
const siwe = new SiweMessage(JSON.parse(credentials?.message as string ?? "{}") as Partial<SiweMessage>);
const nonce = await getCsrfToken({ req });
const fields = await siwe.validate(credentials?.signature || "")
if (fields.nonce !== nonce) {
return null;
}
// Check if user exists
let user = await prisma.user.findUnique({
where: {
address: fields.address
}
});
// Create new user if doesn't exist
if (!user) {
user = await prisma.user.create({
data: {
address: fields.address
}
});
// create account
await prisma.account.create({
data: {
userId: user.id,
type: "credentials",
provider: "Ethereum",
providerAccountId: fields.address
}
});
}
return {
// Pass user id instead of address
// id: fields.address
id: user.id
};
} catch (error) {
// Uncomment or add logging if needed
console.error({ error });
return null;
}
},
})
/**
* ...add more providers here.
*
* Most other providers require a bit more work than the Discord provider. For example, the
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
*
* @see https://next-auth.js.org/providers/github
*/
],
});
// Auth Session
// ========================================================
// ...
Restart the server, sign out, and sign back in. We should now see the user id in the session data, instead of our wallet address.
We should also see in our database that the new user has been created.
Creating Todos TRPC Requests
In order to support the creationg and modification of our new Todo
table, we’ll create a todosRouter
for trpc
that has basic CRUD as all, add, remove, and update.
File: ./src/server/api/routers/todos.ts
// Imports
// ========================================================
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
// Router
// ========================================================
export const todosRouter = createTRPCRouter({
/**
* All todos belonging to the session user
*/
all: protectedProcedure.query(async ({ ctx }) => {
const todos = await ctx.prisma.todo.findMany({
where: {
userId: ctx.session.user.id
}
});
return todos;
}),
/**
* Add todo belonging to the session user
*/
add: protectedProcedure
.input(z.object({ task: z.string() }))
.mutation(async ({ input, ctx }) => {
return await ctx.prisma.todo.create({
data: {
task: input.task,
userId: ctx.session.user.id
}
})
}),
/**
* Remove todo belonging to the session user*
*/
remove: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
return await ctx.prisma.todo.deleteMany({
where: {
id: input.id,
userId: ctx.session.user.id
}
})
}),
/**
* Update todo belonging to the session user
*/
update: protectedProcedure
.input(z.object({
id: z.string(),
task: z.string().optional(),
completed: z.boolean().optional()
}))
.mutation(async ({ input, ctx }) => {
console.log({ input });
const data: { task?: string, completed?: boolean } = {};
if (input.task) {
data.task = input.task;
}
if (typeof input.completed !== "undefined") {
data.completed = input.completed;
}
return await ctx.prisma.todo.updateMany({
data,
where: {
id: input.id,
userId: ctx.session.user.id
}
});
})
});
Next I want to modify our root file to add the router to trpc.
File: ./src/server/api/root.ts
import { createTRPCRouter } from "~/server/api/trpc";
import { exampleRouter } from "~/server/api/routers/example";
import { todosRouter } from "~/server/api/routers/todos";
/**
* This is the primary router for your server.
*
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
example: exampleRouter,
todos: todosRouter
});
// export type definition of API
export type AppRouter = typeof appRouter;
We also need to make a modification to our previous example.ts
file as we changed our Example table to Todo now.
File: ./src/server/api/routers/example.ts
// Imports
// ========================================================
import { z } from "zod";
import {
createTRPCRouter,
publicProcedure,
protectedProcedure,
} from "~/server/api/trpc";
// Router
// ========================================================
export const exampleRouter = createTRPCRouter({
hello: publicProcedure
.input(z.object({ text: z.string() }))
.query(({ input }) => {
return {
greeting: `Hello ${input.text}`,
};
}),
getSecretMessage: protectedProcedure.query(() => {
return "you can now see this secret message!";
}),
});
New Protected Todo Page With Session Support
With the trpc
request built and the user data being stored in the database, we can now create a page that is protected if the user isn’t signed in, and shows their todos if they are.
# FROM: ./t3-siwe
touch ./src/pages/todos.tsx;
If we paste the following code, we should see the functionality for the session access required and the user todo trpc requests.
File: ./src/pages/todos.tsx
// Imports
// ========================================================
import { type NextPage } from "next";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { api } from "~/utils/api";
import { useEffect, useState } from "react";
// Page Component
// ========================================================
const Todos: NextPage = () => {
// State / Props / Hooks
const { data: sessionData } = useSession();
const [todos, setTodos] = useState<{ task: string; id: string; completed: boolean; }[]>([]);
const [newTodo, setNewTodo] = useState('');
// Requests
// - All
const {
data: todosAllData,
isLoading: todosAllIsLoading,
refetch: todosAllRefetch
} = api.todos.all.useQuery(
undefined, // no input
{
// Disable request if no session data
enabled: sessionData?.user !== undefined,
onSuccess: () => {
setNewTodo(''); // reset input form
}
},
);
// - Add
const {
mutate: todosAddMutate,
isLoading: todosAddIsLoading,
} = api.todos.add.useMutation({
onSuccess: async () => {
await todosAllRefetch();
}
});
// - Remove
const {
mutate: todosRemoveMutate,
isLoading: todosRemoveIsLoading,
} = api.todos.remove.useMutation({
onSuccess: async () => {
await todosAllRefetch();
}
});
// - Update
const {
mutate: todosUpdateMutate,
isLoading: todosUpdateIsLoading,
} = api.todos.update.useMutation({
onSuccess: async () => {
await todosAllRefetch();
}
});
// Handle loading for all requests to disable buttons and inputs
const isRequestLoading = todosAllIsLoading || todosAddIsLoading || todosRemoveIsLoading || todosUpdateIsLoading;
// Functions
/**
*
* @param event
*/
const onSubmitForm = (event: React.FormEvent<HTMLFormElement>) => {
console.group('onSubmitForm');
event.preventDefault();
const formData = new FormData(event.currentTarget);
const todoValue = formData.get('todo') as string || '';
console.log({ todoValue });
todosAddMutate({ task: todoValue });
console.groupEnd();
};
/**
*
* @param id
* @returns
*/
const onClickToggleDone = (id: string) => () => {
console.group('onClickToggleDone');
console.log({ id });
todosUpdateMutate({
id,
completed: !todos.find(i => i.id === id)?.completed
})
console.groupEnd();
};
/**
*
* @param id
* @returns
*/
const onClickDelete = (id: string) => () => {
console.group('onClickDelete');
console.log({ id });
todosRemoveMutate({
id
});
console.groupEnd();
};
// Hooks
useEffect(() => {
console.log({ todosAllData });
if (todosAllIsLoading) return;
setTodos(todosAllData || []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [todosAllData]);
// When rendering client side don't display anything until loading is complete
// if (typeof window !== "undefined" && loading) return null
// If no session exists, display access denied message
return (
<>
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16">
<h1 className="text-5xl font-extrabold tracking-tight text-white sm:text-[5rem]">
{!sessionData ? 'Access Denied' : 'Todos'}
</h1>
</div>
<div className="block mb-4 h-10">
<Link className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20" href="/">Home Page</Link>
</div>
{sessionData ? <div className="w-full flex justify-center items-center flex-col">
<form onSubmit={onSubmitForm} className="w-full max-w-md block mb-4">
<div className="mb-4">
<label className="block text-white/50 mb-2">New Todo</label>
<input disabled={isRequestLoading} onChange={(e) => setNewTodo(e.target.value)} className="disabled:opacity-30 h-10 bg-white rounded px-4 w-full" type="text" name="todo" value={newTodo} />
</div>
<div className="mb-4">
<button disabled={isRequestLoading} type="submit" className="disabled:opacity-30 disabled:hover:bg-white/10 mr-4 w-full rounded-full bg-white/10 px-6 font-semibold text-white no-underline transition hover:bg-white/20 h-10">Add</button>
</div>
</form>
{todos.length === 0
? <p className="text-white">(No todos yet!)</p>
: <ul className="w-full max-w-md block">
{todos.map((todo, key) => <li key={`todo-${key}-${todo.id}`} className={`flex justify-between items-center ${key % 2 === 0 ? 'bg-white/5 hover:bg-white/10' : 'bg-white/10 hover:bg-white/20'} transition-colors ease-in-out duration-200 rounded-tl rounded-tr p-4 border-b border-b-white/50`}>
<span className={`text-white font-semibold ${todo.completed ? 'line-through' : ''}`}>{todo.task}</span>
<span>
<button disabled={isRequestLoading} onClick={onClickDelete(todo.id)} className="disabled:opacity-30 disabled:hover:bg-white/10 mr-4 rounded-full bg-white/10 px-6 font-semibold text-white no-underline transition hover:bg-white/20 h-10">Delete</button>
<button disabled={isRequestLoading} onClick={onClickToggleDone(todo.id)} className="disabled:opacity-30 disabled:hover:bg-white/10 rounded-full bg-white/10 px-6 font-semibold text-white no-underline transition hover:bg-white/20 h-10">{todo.completed ? 'Undo' : 'Done'}</button>
</span>
</li>)}
</ul>
}
</div> : null}
</main>
</>
);
};
// Exports
// ========================================================
export default Todos;
We’re also going to make one small modification to our index.tsx
to give it a link to the todos page.
File: ./src/pages/index.tsx
// ...
// Page Component
// ========================================================
const Home: NextPage = () => {
// Requests
const hello = api.example.hello.useQuery({ text: "from tRPC" });
// Render
return (
<>
<Head>
<title>Create T3 App SIWE</title>
<meta name="description" content="Generated by create-t3-app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16 ">
<h1 className="text-5xl font-extrabold tracking-tight text-white sm:text-[5rem]">
Create <span className="text-[hsl(280,100%,70%)]">T3</span> App SIWE
</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8">
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/usage/first-steps"
target="_blank"
>
<h3 className="text-2xl font-bold">First Steps →</h3>
<div className="text-lg">
Just the basics - Everything you need to know to set up your
database and authentication.
</div>
</Link>
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/introduction"
target="_blank"
>
<h3 className="text-2xl font-bold">Documentation →</h3>
<div className="text-lg">
Learn more about Create T3 App, the libraries it uses, and how
to deploy it.
</div>
</Link>
</div>
<div className="flex flex-col items-center gap-2">
<div className="block mb-4 h-10">
{/* HERE - new todo link */}
<Link className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20" href="/todos">(Protected) Todos Page</Link>
</div>
<p className="text-2xl text-white block mb-4">
{hello.data ? hello.data.greeting : "Loading tRPC query..."}
</p>
<AuthShowcase />
</div>
</div>
</main>
</>
);
};
// Exports
// ========================================================
export default Home;
Now if we take a look at our full app we should see the home page, access denied if no session to the todo page, and managing todos stored to the database.
Bonus Blockies Image
One last thing, I’m going to add is support for a blockies image generated by the user’s wallet address.
# FROM: ./t3-siwe
pnpm add @codingwithmanny/blockies; # npm install @codingwithmanny/blockies;
File: ./src/pages/index.tsx
// Imports
// ========================================================
import { type NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
// Add this new package
import Image from "next/image";
import { useEffect, useState } from "react";
import { getCsrfToken, signIn, signOut, useSession } from "next-auth/react";
import { api } from "~/utils/api";
// Add this new package
import { renderDataURI } from "@codingwithmanny/blockies";
// SIWE Integration
import { SiweMessage } from "siwe";
import { useAccount, useConnect, useDisconnect, useSignMessage, useNetwork } from "wagmi";
import { InjectedConnector } from 'wagmi/connectors/injected';
// Auth Component
// ========================================================
const AuthShowcase: React.FC = () => {
// Hooks
const { data: sessionData } = useSession();
const { data: secretMessage } = api.example.getSecretMessage.useQuery(
undefined, // no input
{ enabled: sessionData?.user !== undefined },
);
// State
const [showConnection, setShowConnection] = useState(false);
// Wagmi Hooks
const { signMessageAsync } = useSignMessage();
const { address, isConnected } = useAccount();
const { connect } = useConnect({
connector: new InjectedConnector(),
});
const { disconnect } = useDisconnect();
const { chain } = useNetwork();
// Functions
/**
* Attempts SIWE and establish session
*/
const onClickSignIn = async () => {
try {
const message = new SiweMessage({
domain: window.location.host,
address: address,
statement: "Sign in with Ethereum to the app.",
uri: window.location.origin,
version: "1",
chainId: chain?.id,
// nonce is used from CSRF token
nonce: await getCsrfToken(),
})
const signature = await signMessageAsync({
message: message.prepareMessage(),
})
await signIn("credentials", {
message: JSON.stringify(message),
redirect: false,
signature,
});
} catch (error) {
window.alert(error);
}
};
/**
* Sign user out
*/
const onClickSignOut = async () => {
await signOut();
};
// Hooks
/**
* Handles hydration issue
* only show after the window has finished loading
*/
useEffect(() => {
setShowConnection(true);
}, []);
// Render
return (
<div className="flex flex-col items-center justify-center gap-4">
{sessionData
? <div className="mb-4 text-center">
{sessionData ? <div className="mb-4">
<label className="block text-white/80 mb-2">Logged in as</label>
{/* Add this new line */}
{sessionData?.user?.id ? <Image width={"80"} height={"80"} alt={`${sessionData.user.id}`} className="mx-auto my-4 border-8 border-white/30" src={`${renderDataURI({ seed: sessionData.user.id, size: 10, scale: 8 })}`} /> : null}
<code className="block p-4 text-white bg-black/20 rounded">{JSON.stringify(sessionData)}</code>
</div> : null}
{secretMessage ? <p className="mb-4">
<label className="block text-white/80 mb-2">Secret Message</label>
<code className="block p-4 text-white bg-black/20 rounded">{secretMessage}</code>
</p> : null}
<button
className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
onClick={onClickSignOut as () => void}
>
Sign Out
</button>
</div>
: showConnection
? <div className="mb-4">
{isConnected
? <button
className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
onClick={onClickSignIn as () => void}
>
Sign In
</button>
: null
}
</div>
: null
}
{showConnection
? <div className="text-center">
{address
? <p className="mb-4">
<code className="block p-4 text-white bg-black/20 rounded">{address}</code>
</p>
: null
}
<button
className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
onClick={() => !isConnected ? connect() : disconnect()}
>
{!isConnected ? 'Connect Wallet' : 'Disconnect'}
</button>
</div>
: null}
</div>
);
};
// Page Component
// ========================================================
// ...
We’ve successfully completed the entire process of adding SIWE with the T3 Stack.
Full Code Repository
If you want to see the final product for the full code repository, check out t3-app-siwe.
What’s Next?
This was a first attempt in trying to get more adoption for developers to start using web3 tools within more traditional web3 stacks, libraries, and frameworks to make it easier to get started.
The goal would be either to build a Auth Provider that works slightly different for wallets and signing messages like Sign-In With Ethereum, and just overall get better adoption for web2 and web3 tools working together.
If you got value from this, please also follow me on twitter (where I’m quite active) @codingwithmanny and instagram at @codingwithmanny.
Oh! I’m also on YouTube now as @codingwithmanny.