How to Authenticate Users with Solana Wallet Adapter
This tutorial covers how to create full-stack Web3 authentication for the Solana wallet adaptor, using the popular NextJS framework.
Introduction
This tutorial shows you how to create a NextJS application that allows users to log in using any wallet that uses the Solana wallet adapter.
After Web3 wallet authentication, the next-auth library creates a session cookie with an encrypted JWT (JWE) stored inside. It contains session info (such as an address, signed message, and expiration time) in the user's browser. It's a secure way to store users' info without a database, and it's impossible to read/modify the JWT without a secret key.
Once the user is logged in, they will be able to visit a page that displays all their user data.
You can find the repository with the final code here: GitHub.
You can find the final dapp with implemented style on our GitHub.
Prerequisites
- Create a Moralis account.
- Install and set up Visual Studio.
- Create your NextJS dapp (you can create it using create-next-app or follow the NextJS dapp tutorial).
Install the Required Dependencies
- Install
@moralisweb3/next
(if not installed),next-auth
and@web3uikit/core
dependencies:
- npm
- Yarn
- pnpm
npm install @moralisweb3/next next-auth @web3uikit/core
yarn add @moralisweb3/next next-auth @web3uikit/core
pnpm add @moralisweb3/next next-auth @web3uikit/core
- To implement authentication using a Web3 wallet (e.g., Phantom), we need to use a Solana Web3 library. For the tutorial, we will use wagmi. So, let's install the
wagmi
dependency:
- npm
- Yarn
- pnpm
npm install bs58 tweetnacl \
@solana/wallet-adapter-base \
@solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui \
@solana/wallet-adapter-wallets \
@solana/web3.js
yarn add bs58 tweetnacl \
@solana/wallet-adapter-base \
@solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui \
@solana/wallet-adapter-wallets \
@solana/web3.js
pnpm add bs58 tweetnacl \
@solana/wallet-adapter-base \
@solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui \
@solana/wallet-adapter-wallets \
@solana/web3.js
- Add new environment variables in your
.env.local
file in the app root:
- APP_DOMAIN: RFC 4501 DNS authority that is requesting the signing.
- MORALIS_API_KEY: You can get it here.
- NEXTAUTH_URL: Your app address. In the development stage, use
http://localhost:3000
. - NEXTAUTH_SECRET: Used for encrypting JWT tokens of users. You can put any value here or generate it on
https://generate-secret.now.sh/32
. Here's an.env.local
example:
APP_DOMAIN=amazing.finance
MORALIS_API_KEY=xxxx
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=7197b3e8dbee5ea6274cab37245eec212
Keep your NEXTAUTH_SECRET
value in secret to prevent security problems.
Every time you modify the .env.local
file, you need to restart your dapp.
Wrapping App with Solana Wallet Provider
and SessionProvider
- Create the
pages/_app.jsx
file. We need to wrap our pages withSolana Wallet Provider
(docs) andSessionProvider
(docs):
import "../styles/globals.css";
import { SessionProvider } from "next-auth/react";
import {
ConnectionProvider,
WalletProvider,
} from "@solana/wallet-adapter-react";
import {
PhantomWalletAdapter,
SolflareWalletAdapter,
} from "@solana/wallet-adapter-wallets";
import { clusterApiUrl } from "@solana/web3.js";
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
import { useMemo } from "react";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
function MyApp({ Component, pageProps }) {
const network = WalletAdapterNetwork.Devnet;
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
const wallets = useMemo(
() => [new PhantomWalletAdapter(), new SolflareWalletAdapter({ network })],
[network]
);
return (
<SessionProvider session={pageProps.session}>
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
<Component {...pageProps} />
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
</SessionProvider>
);
}
export default MyApp;
NextJS uses the App
component to initialize pages. You can override it and control the page initialization. Check out the NextJS docs.
Configure Next-Auth and MoralisNextAuth
- Create a new file,
pages/api/auth/[...nextauth].ts
, with the following content:
- [...nextauth].ts
- [...nextauth].js
import NextAuth from "next-auth";
import { MoralisNextAuthProvider } from "@moralisweb3/next";
export default NextAuth({
providers: [MoralisNextAuthProvider()],
// adding user info to the user session object
callbacks: {
async jwt({ token, user }) {
if (user) {
token.user = user;
}
return token;
},
async session({ session, token }) {
(session as { user: unknown }).user = token.user;
return session;
},
},
});
import NextAuth from "next-auth";
import { MoralisNextAuthProvider } from "@moralisweb3/next";
export default NextAuth({
providers: [MoralisNextAuthProvider()],
// adding user info to the user session object
callbacks: {
async jwt({ token, user }) {
if (user) {
token.user = user;
}
return token;
},
async session({ session, token }) {
session.user = token.user;
return session;
},
},
});
- Add an authenticating config to the
pages/api/moralis/[...moralis].ts
:
- [...moralis].ts
- [...moralis].js
import { MoralisNextApi } from "@moralisweb3/next";
const DATE = new Date();
const FUTUREDATE = new Date(DATE);
FUTUREDATE.setDate(FUTUREDATE.getDate() + 1);
const { MORALIS_API_KEY, APP_DOMAIN, NEXTAUTH_URL } = process.env;
if (!MORALIS_API_KEY || !APP_DOMAIN || !NEXTAUTH_URL) {
throw new Error(
"Missing env variables. Please add the required env variables."
);
}
export default MoralisNextApi({
apiKey: MORALIS_API_KEY,
authentication: {
timeout: 120,
domain: APP_DOMAIN,
uri: NEXTAUTH_URL,
expirationTime: FUTUREDATE.toISOString(),
statement: "Sign message to authenticate.",
},
});
import { MoralisNextApi } from "@moralisweb3/next";
const DATE = new Date();
const FUTUREDATE = new Date(DATE);
FUTUREDATE.setDate(FUTUREDATE.getDate() + 1);
const { MORALIS_API_KEY, APP_DOMAIN, NEXTAUTH_URL } = process.env;
if (!MORALIS_API_KEY || !APP_DOMAIN || !NEXTAUTH_URL) {
throw new Error(
"Missing env variables. Please add the required env variables."
);
}
export default MoralisNextApi({
apiKey: MORALIS_API_KEY,
authentication: {
timeout: 120,
domain: APP_DOMAIN,
uri: NEXTAUTH_URL,
expirationTime: FUTUREDATE.toISOString(),
statement: "Sign message to authenticate.",
},
});
Create Wallet Component
- Create a new file under
app/components/loginBtn/walletAdaptor.tsx
:
- walletAdaptor.tsx
- walletAdaptor.jsx
import { useEffect } from "react";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
require("@solana/wallet-adapter-react-ui/styles.css");
import base58 from "bs58";
import { signIn, signOut } from "next-auth/react";
import { useAuthRequestChallengeSolana } from "@moralisweb3/next";
import React from "react";
export default function WalletAdaptor() {
const { publicKey, signMessage, disconnecting, disconnect, connected } =
useWallet();
const { requestChallengeAsync, error } = useAuthRequestChallengeSolana();
const signCustomMessage = async () => {
if (!publicKey) {
throw new Error("Wallet not avaiable to process request.");
}
const address = publicKey.toBase58();
const challenge = await requestChallengeAsync({
address,
network: "devnet",
});
const encodedMessage = new TextEncoder().encode(challenge?.message);
if (!encodedMessage) {
throw new Error("Failed to get encoded message.");
}
const signedMessage = await signMessage?.(encodedMessage);
const signature = base58.encode(signedMessage as Uint8Array);
try {
const authResponse = await signIn("moralis-auth", {
message: challenge?.message,
signature,
network: "Solana",
redirect: false,
});
if (authResponse?.error) {
throw new Error(authResponse.error);
}
} catch (e) {
disconnect();
console.log(e);
return;
}
};
useEffect(() => {
if (error) {
disconnect();
console.log(error);
}
}, [disconnect, error]);
useEffect(() => {
if (disconnecting) {
signOut({ redirect: false });
}
}, [disconnecting]);
useEffect(() => {
connected && signCustomMessage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connected]);
return <WalletMultiButton />;
}
import { useEffect } from "react";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
require("@solana/wallet-adapter-react-ui/styles.css");
import base58 from "bs58";
import { signIn, signOut } from "next-auth/react";
import { useAuthRequestChallengeSolana } from "@moralisweb3/next";
import React from "react";
export default function WalletAdaptor() {
const { publicKey, signMessage, disconnecting, disconnect, connected } =
useWallet();
const { requestChallengeAsync, error } = useAuthRequestChallengeSolana();
const signCustomMessage = async () => {
if (!publicKey) {
throw new Error("Wallet not avaiable to process request.");
}
const address = publicKey.toBase58();
const challenge = await requestChallengeAsync({
address,
network: "devnet",
});
const encodedMessage = new TextEncoder().encode(challenge?.message);
if (!encodedMessage) {
throw new Error("Failed to get encoded message.");
}
const signedMessage = await signMessage?.(encodedMessage);
const signature = base58.encode(signedMessage);
try {
const authResponse = await signIn("moralis-auth", {
message: challenge?.message,
signature,
network: "Solana",
redirect: false,
});
if (authResponse?.error) {
throw new Error(authResponse.error);
}
} catch (e) {
disconnect();
console.log(e);
return;
}
};
useEffect(() => {
if (error) {
disconnect();
console.log(error);
}
}, [disconnect, error]);
useEffect(() => {
if (disconnecting) {
signOut({ redirect: false });
}
}, [disconnecting]);
useEffect(() => {
connected && signCustomMessage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connected]);
return <WalletMultiButton />;
}
Create Page to Sign-In
- Create a new page file,
pages/index.jsx
, with the following content:
- You can get the app CSS from GitHub to style the app.
import React, { useEffect, useTransition } from "react";
import styles from "../styles/Home.module.css";
import { useRouter } from "next/router";
import { Typography } from "@web3uikit/core";
import { useSession } from "next-auth/react";
import WalletAdaptor from "../app/components/loginBtn/walletAdaptor";
export default function Home() {
const router = useRouter();
const { data: session, status } = useSession();
const [isPending, startTransition] = useTransition();
useEffect(() => {
startTransition(() => {
session && status === "authenticated" && router.push("./user");
});
}, [session, status]);
useEffect(() => {
startTransition(() => {
session && console.log(session);
});
}, [session]);
return (
<div className={styles.body}>
{!isPending && (
<div className={styles.card}>
<>
{!session ? (
<>
<Typography variant="body18">
Select Wallet for Authentication
</Typography>
<br />
<WalletAdaptor />
</>
) : (
<Typography variant="caption14">Loading...</Typography>
)}
</>
</div>
)}
</div>
);
}
Logout and User Profile Component
- Create components to perform the logout operation and to show the user data.
- logoutBtn.js
- userData.js
// File path
// app/components/logoutBtn/logoutBtn.js
import React from "react";
import { Button } from "@web3uikit/core";
import { signOut } from "next-auth/react";
export default function LogoutBtn() {
return (
<Button text="Logout" theme="outline" onClick={() => signOut()}></Button>
);
}
// File path
// app/components/logoutBtn/userData.js
import React from "react";
import styles from "../../../styles/User.module.css";
import { Typography } from "@web3uikit/core";
import { useSession } from "next-auth/react";
export default function UserData() {
const { data: session, status } = useSession();
if (session) {
return (
<div className={styles.data}>
<div className={styles.dataCell}>
<Typography variant="subtitle2">Profile Id:</Typography>
<div className={styles.address}>
<Typography variant="body16">{session?.user.profileId}</Typography>
</div>
</div>
<div className={styles.dataCell}>
<Typography variant="subtitle2">Account:</Typography>
<div className={styles.address}>
{/* account address */}
<Typography copyable variant="body16">
{session?.user.address}
</Typography>
</div>
</div>
<div className={styles.dataCell}>
<Typography variant="subtitle2">Network:</Typography>
<div className={styles.address}>
<Typography variant="body16">{session?.user.network}</Typography>
</div>
</div>
<div className={styles.dataCell}>
<Typography variant="subtitle2">ExpTime:</Typography>
<div className={styles.address}>
<Typography variant="body16">
{session?.user.expirationTime}
</Typography>
</div>
</div>
</div>
);
}
}
Showing the User Profile
- Let's create a
user.jsx
page to view user data when the user is logged in.
import React, { useEffect, useTransition } from "react";
import styles from "../styles/User.module.css";
import { getSession, signOut } from "next-auth/react";
import UserData from "../app/components/userData/userData";
import LogoutBtn from "../app/components/logoutBtn/logoutBtn";
import { WalletDisconnectButton } from "@solana/wallet-adapter-react-ui";
import { useWallet } from "@solana/wallet-adapter-react";
require("@solana/wallet-adapter-react-ui/styles.css");
export async function getServerSideProps(context) {
const session = await getSession(context);
if (!session) {
return { redirect: { destination: "/" } };
}
return {
props: { userSession: session },
};
}
export default function Home({ userSession }) {
const { publicKey, disconnecting, connected } = useWallet();
const [isPending, startTransition] = useTransition();
console.log(userSession);
useEffect(() => {
startTransition(() => {
publicKey && console.log(publicKey.toBase58());
});
}, [publicKey]);
useEffect(() => {
startTransition(() => {
disconnecting && signOut();
});
}, [disconnecting]);
useEffect(() => {
startTransition(() => {
console.log({ disconnecting });
});
}, [disconnecting]);
if (userSession) {
return (
<div className={styles.body}>
{!isPending && (
<div className={styles.card}>
<>
<UserData />
<div className={styles.buttonsRow}>
{connected || disconnecting ? (
<WalletDisconnectButton />
) : (
<LogoutBtn />
)}
</div>
</>
</div>
)}
</div>
);
}
}
Testing with any Solana Wallet
Visit http://localhost:3000
to test the authentication.
- Click on the
Select Wallet
button to select and connect to wallet:
- Connect to the Solana wallet extension
- Sign the message:
- After successful authentication, you will be redirected to the
/user
page:
And that completes the authentication process for Solana wallets using the Solana wallet adapter.