Let's talk about wallet based auth in NestJS. What this is, is basically an auth system where the user is authenticated and authorized based on their web3 wallet. We'll be doing this with a Solana wallet. The process is quite straightforward:
- User requests a nonce (a single-use random number that prevents replay attacks)
- Backend sends the nonce
- User signs the nonce with their wallet address and returns the signed nonce back to the backend
- Backend verifies that the nonce was signed by the right address
From this point on it's your regular auth flow and you can hook this up to any existing JWT or session auth system you've implemented.
One thing to note is that a frontend is required for this or at least some way for the client to sign nonces with their web3 wallet. We'll be using @solana/kit SDK for this as it's the modern implementation of the @solana/web3.js SDK and recommended for production use by Anza.
Backend
The backend's job is to generate unique challenges (nonces), verify that signatures were created by the claimed wallet address, and issue JWT tokens on successful authentication.
We'll need to install @solana/kit as well as redis since we will be using that to store our nonces. Redis is ideal here because it provides fast in-memory storage with built-in TTL (time-to-live) expiration, automatically cleaning up old nonces without manual intervention.
npm install @solana/kit redis
Redis Service
Let's set up our redis client and service.
In NestJS, when you're working with non-class instances like Redis clients, you can't use the standard class-based dependency injection. Instead, you use string tokens as injection identifiers. The Redis client is created by a factory function (not instantiated via new RedisService()), so TypeScript can't use the class itself as a token. String constants provide a unique identifier that NestJS can use to register and retrieve the instance.
This is why we define REDIS_CLIENT as a constant string token that we'll use to register and inject our Redis client throughout the application.
//redis.constants.ts
export const REDIS_CLIENT = 'REDIS_CLIENT';
We need two specific methods here. The setNonce method stores the nonce with a 5-minute expiration using Redis's EX option, which automatically cleans up old nonces and prevents replay attacks.
The getAndDeleteNonce method atomically retrieves and deletes the nonce in one operation, ensuring each nonce can only be used once for verification. This pattern prevents an attacker from reusing a previously signed nonce.
//redis.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { REDIS_CLIENT } from './redis.constants';
import type { RedisClientType } from 'redis';
@Injectable()
export class RedisService {
constructor(@Inject(REDIS_CLIENT) private readonly redis: RedisClientType) {}
// Store nonce with 5-minute expiration
async setNonce(address: string, nonce: string): Promise<void> {
await this.redis.set(`nonce:${address}`, nonce, { EX: 300 });
}
// Retrieve and immediately delete nonce to prevent replay attacks
async getAndDeleteNonce(address: string): Promise<string | null> {
const nonce = await this.redis.get(`nonce:${address}`);
if (nonce) {
await this.redis.del(`nonce:${address}`);
}
return nonce;
}
}
We're using NestJS's ConfigService to manage environment variables in a type-safe way. This is better than directly accessing process.env because it centralizes configuration, provides validation, and makes testing easier. You can validate required variables at startup (fail fast), type-check them, and mock them in tests without polluting the global process object.
The @Global() decorator makes this module available throughout your application without needing to import it into every module. Make sure to set up ConfigModule in your AppModule like this:
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
// ... other imports
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Makes ConfigModule available globally
}),
// ... other modules
],
// ... controllers and providers
})
export class AppModule {}
Now we can set up our Redis module:
//redis.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
import { REDIS_CLIENT } from './redis.constants';
import { ConfigService } from '@nestjs/config';
import { createClient } from 'redis';
@Global()
@Module({
providers: [
{
provide: REDIS_CLIENT,
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
const client = createClient({
url: configService.get<string>('REDIS_URL'),
});
client.on('error', (err) => console.error('Redis client error', err));
await client.connect();
return client;
},
},
RedisService,
],
exports: [REDIS_CLIENT, RedisService],
})
export class RedisModule {}
Auth Service
With Redis configured to handle nonce storage, we can build the authentication logic. The auth service will generate nonces, create SIWS-compliant messages, verify signatures, and issue JWT tokens. Let's implement our service logic.
We need to provide a way for the frontend to request a nonce. We also need to use our redis methods to store the nonce we provide to be used during verification later.
We use randomBytes from Node's crypto module to generate cryptographically secure random values. The 16 bytes of random data are then converted to base64url encoding, which is URL-safe and produces a string without padding characters. This encoding ensures the nonce can be safely included in URLs and JSON without escaping issues, while 16 bytes provides sufficient entropy (2^128 possible values) to prevent collision attacks. This gives us a unique, unpredictable nonce for each authentication attempt.
import { randomBytes } from 'crypto';
generateNonce(): string {
return randomBytes(16).toString('base64url');
}
The message format follows the Sign-In with Solana (SIWS) standard, which is analogous to Sign-In with Ethereum (SIWE). This standardized format includes the domain requesting authentication, the user's wallet address, a human-readable message, the unique nonce, and a timestamp. This structure ensures the user knows exactly what they're signing and prevents phishing attacks where a malicious site might try to get users to sign messages intended for other domains.
async getChallenge(address: string) {
const nonce = this.generateNonce();
await this.redisService.setNonce(address, nonce);
// Standard SIWS message format for wallet authentication
const message = `titan-app.com wants you to sign in with your Solana account:\n${address}\n\nSign this message to authenticate.\n\nNonce: ${nonce}\nTimestamp: ${Date.now()}`;
return { message, nonce };
}
The signature verification method is the heart of the authentication system. It retrieves the stored nonce, reconstructs the exact message that was signed, and uses Solana's cryptographic primitives to verify that the signature was created by the private key corresponding to the claimed wallet address. If any part of this fails, authentication is rejected.
import { getAddressFromPublicKey, getBase58Decoder, getBase58Encoder } from '@solana/kit';
async verifySolanaSignature(data: {
address: string;
signatureBase58: Uint8Array;
message: Uint8Array;
nonce: string;
}): Promise<boolean> {
const { address, signatureBase58, message, nonce } = data;
// Retrieve and delete the nonce to prevent replay attacks
const storedNonce = await this.redisService.getAndDeleteNonce(address);
if (!storedNonce || storedNonce !== nonce) {
return false;
}
try {
// Decode the signature and message from base58
const signature = getBase58Decoder().decode(signatureBase58);
const messageBytes = getBase58Decoder().decode(message);
// Verify the signature matches the address
const publicKey = getAddressFromPublicKey(
getBase58Decoder().decode(address)
);
// Use Solana's signature verification
const isValid = await verifySignature(publicKey, messageBytes, signature);
return isValid;
} catch (error) {
console.error('Signature verification error:', error);
return false;
}
}
The validateUserWithWallet method either finds an existing user by wallet address or creates a new one. This is where you'd integrate with your user management system. In this example, we're using Prisma to handle database operations, but you could adapt this to any ORM or database layer.
async validateUserWithWallet(address: string): Promise<UserEntity | null> {
// Find existing user by wallet address
let user = await this.prisma.user.findUnique({
where: { walletAddress: address },
});
// If no user exists, create one
if (!user) {
user = await this.prisma.user.create({
data: {
walletAddress: address,
},
});
}
return user;
}
The login function creates JWT tokens after successful authentication. We build a payload containing the user's ID and device information, then conditionally add email and wallet address if they exist. The function returns both an access token for immediate use and a refresh token for obtaining new access tokens when the current one expires. The refresh token is handled by a separate service that manages longer-lived tokens with different expiration rules.
async login(
user: UserEntity,
deviceInfo: string,
walletAddress?: string,
): Promise<{
access_token: string;
refresh_token: string;
}> {
const payload = {
sub: user.id,
deviceInfo,
};
if (user.email) {
payload['email'] = user.email;
}
if (walletAddress) {
payload['walletAddress'] = walletAddress;
}
const refresh_token = await this.jwtRefreshService.create(payload);
return {
access_token: this.jwtService.sign(payload),
refresh_token,
};
}
Wallet Auth Guard
Now we need a way to protect our login endpoint. NestJS guards intercept incoming requests before they reach the controller, making them perfect for authentication logic.
The guard is responsible for intercepting requests to protected endpoints and verifying that the wallet signature is valid before allowing the request to proceed. It extracts the signed data from the request body, verifies the signature using our auth service, finds or creates the user, and attaches both the user entity and wallet address to the request object for use in the controller.
// guards/wallet-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { AuthService } from '../auth.service';
import { UnauthorizedException } from '@nestjs/common';
@Injectable()
export class LocalWalletAuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { address, signatureBase58, message, nonce } = request.body;
// Verify signature first
const isValid = await this.authService.verifySolanaSignature({
address,
signatureBase58,
message,
nonce,
});
if (!isValid) {
throw new UnauthorizedException('Invalid signature');
}
// Find or create user
const user = await this.authService.validateUserWithWallet(address);
if (!user) {
throw new UnauthorizedException('Could not validate user');
}
// Attach user and address to request for use in controller
request.user = user;
request.address = address;
return true;
}
}
Auth Controller
With our guard handling verification, the controller becomes simple. It just needs to provide nonces to unauthenticated users and issue tokens after the guard confirms valid signatures.
In the controller, we have two endpoints. The loginWithWallet endpoint uses our LocalWalletAuthGuard which verifies the signature and attaches both the user entity and wallet address to the request object. By the time we reach this controller method, we know the authentication is valid. We extract device information from the user-agent header, call the login service, set the refresh token as an httpOnly cookie for security, and return the access token. The getChallenge endpoint is marked with @Public() decorator since users need to request a nonce before they can authenticate. It simply returns the SIWS message and nonce for the given wallet address.
@Post('login-with-wallet')
@UseGuards(LocalWalletAuthGuard)
async loginWithWallet(
@Request() req: RequestType & { user: UserEntity; address: string },
@Response({ passthrough: true }) res: ResponseType,
) {
const deviceInfo = req.headers['user-agent'] || 'Unknown Device';
const { access_token, refresh_token } = await this.authService.login(
req.user,
deviceInfo,
req.address,
);
// Set refresh token as httpOnly cookie for security
res.cookie('titan_refresh_token', refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
return { access_token };
}
@Public()
@Get('nonce/:address')
@ApiOperation({ summary: 'Request nonce for wallet-based authentication' })
@ApiResponse({
description: 'Nonce and SIWS message sent successfully.',
status: 200,
example: {
message:
'titan-app.com wants you to sign in with your Solana account:\n${address}\n\nSign this message to authenticate.\n\nNonce: ${nonce}\nTimestamp: ${Date.now()}',
nonce: '1234xlrhex',
},
})
async getChallenge(@Param('address') address: string) {
return await this.authService.getChallenge(address);
}
Backend Summary
That covers the backend implementation. We've set up a Redis-backed nonce system that generates unique challenges for each authentication attempt, an auth service that creates SIWS-compliant messages and manages JWT token generation, a guard that verifies signatures using Solana's cryptographic primitives, and controller endpoints that handle the nonce request and login flow. The signature verification ensures that only the holder of the wallet's private key can authenticate, while the nonce system prevents replay attacks.
Frontend
The backend is now ready to issue nonces and verify signatures. The frontend's job is to request these nonces, get the user to sign them with their wallet's private key (which never leaves their browser), and send the signed message back for verification. Now let's implement the frontend of our auth system.
Now let's implement the frontend of our auth system. The user needs to sign our message with their private key. It is important this private key stays secure and never leaves the frontend. A tool that facilitates this is a wallet. Since we're using Solana we'll be using the Phantom wallet.
The setup steps are straightforward. First, install Phantom as a browser extension. Once installed, create a new wallet or import an existing one. Then navigate to Settings, go to Developer Settings, and enable Testnet mode. In Testnet mode, select Solana Devnet. You could use Localnet if you have a test validator running locally or Testnet for a shared testing environment, but Devnet provides a good balance of accessibility and realistic network conditions.
Once all this is done we can start hacking. We'll spin up a small React frontend. This is just for speed and general accessibility. You can implement this in any frontend stack as long as you understand the core steps. I used Vite to set up my React project with TypeScript and Tailwind. We should also install the @solana/kit package, which automatically includes @solana/react and other necessary dependencies, along with @wallet-standard/react-core.
npm install @solana/kit @wallet-standard/react-core
After we're done with project setup, the first thing we want to do is define our Solana client instance. This will be used to communicate with the Solana chain via its RPC endpoints.
// lib/solana-client.ts
import type {
Rpc,
RpcSubscriptions,
SolanaRpcApi,
SolanaRpcSubscriptionsApi,
} from "@solana/kit";
export type Client = {
rpc: Rpc<SolanaRpcApi>;
rpcSubscriptions: RpcSubscriptions<SolanaRpcSubscriptionsApi>;
};
// lib/helpers.ts
import { createSolanaRpc, createSolanaRpcSubscriptions } from "@solana/kit";
import type { Client } from "./solana-client";
let client: Client | undefined;
export function createClient(): Client {
if (!client) {
client = {
rpc: createSolanaRpc(`${import.meta.env.SOLANA_RPC_URL}`),
rpcSubscriptions: createSolanaRpcSubscriptions(
`${import.meta.env.SOLANA_RPC_SUBSCRIPTIONS_URL}`
),
};
}
return client;
}
Make sure your .env file contains the Devnet URLs:
SOLANA_RPC_URL=https://api.devnet.solana.com
SOLANA_RPC_SUBSCRIPTIONS_URL=wss://api.devnet.solana.com
With the Solana client configured, we need functions to communicate with our backend. In our features/auth/api folder, we'll create two API calls: one to request a nonce and another to submit the signed message. Let's define them both.
// features/auth/types
export type RequestNonceOutput = {
message: string;
nonce: string;
};
export type VerifyNonceInput = {
address: string;
signatureBase58: Uint8Array<ArrayBufferLike>;
message: Uint8Array<ArrayBufferLike>;
nonce: string;
};
export type LoginResponse = {
access_token: string;
};
// features/auth/api/requestNonce.ts
import type { RequestNonceOutput } from "../types";
export async function requestNonce(
address: string
): Promise<RequestNonceOutput> {
const response = await fetch(
`${import.meta.env.VITE_API_URL}/auth/nonce/${address}`
);
if (!response.ok) {
throw new Error(`Failed to request nonce: ${response.statusText}`);
}
const data = await response.json();
// Validate response structure
if (!data.message || !data.nonce) {
throw new Error('Invalid response format from nonce endpoint');
}
return data as RequestNonceOutput;
}
// features/auth/api/loginWithWallet.ts
import type { VerifyNonceInput, LoginResponse } from "../types";
export async function loginWithWallet(
signedData: VerifyNonceInput
): Promise<LoginResponse> {
const response = await fetch(
`${import.meta.env.VITE_API_URL}/auth/login-with-wallet`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(signedData),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `Login failed: ${response.statusText}`
);
}
const data = await response.json();
// Validate response contains access token
if (!data.access_token) {
throw new Error('Invalid response format from login endpoint');
}
return data as LoginResponse;
}
These are going to serve as our connection to the backend we made previously.
Before we build the UI components, we need to manage wallet connection state across the app. React Context provides a clean way to share the selected wallet without prop drilling.
As this is only an auth demonstration it's not going to be anything fancy, just enough to implement the authentication flow and some state to show we're logged in.
First off, our wallet account context. You can use some other way to manage context in your application. Here we'll be using the React Context API.
// src/context/SelectedWalletAccountContext.tsx
import { createContext } from "react";
import type { UiWallet, UiWalletAccount } from "@wallet-standard/react-core";
export type SelectedWalletAccountState = UiWalletAccount | undefined;
export const SelectedWalletAccountContext = createContext<
readonly [
selectedWalletAccount: SelectedWalletAccountState,
setSelectedWalletAccount: React.Dispatch<
React.SetStateAction<SelectedWalletAccountState>
>,
selectedWallet: UiWallet | undefined
]
>([
undefined,
function setSelectedWalletAccount() {
/* empty */
},
undefined,
]);
The wallet account context provider handles persistence and synchronization of the selected wallet across sessions. When a user selects a wallet, we save a storage key composed of the wallet name and account address to localStorage. On subsequent visits, the provider attempts to restore the previously selected wallet if it's still available. The provider also handles cleanup when wallets disconnect and ensures the selected account stays synchronized with the actual connected wallets from the Wallet Standard registry.
// src/context/SelectedWalletAccountContextProvider.tsx
import { useEffect, useMemo, useState } from "react";
import {
SelectedWalletAccountContext,
type SelectedWalletAccountState,
} from "./SelectedWalletAccountContext";
import {
getUiWalletAccountStorageKey,
uiWalletAccountBelongsToUiWallet,
uiWalletAccountsAreSame,
useWallets,
type UiWallet,
type UiWalletAccount,
} from "@wallet-standard/react-core";
const STORAGE_KEY = "solana-wallet-standard-react:selected-wallet-and-address";
let wasSetterInvoked = false;
// Attempts to restore previously selected wallet from localStorage
function getSavedWalletAccountAndWallet(
wallets: readonly UiWallet[]
):
| { account: UiWalletAccount | undefined; wallet: UiWallet | undefined }
| undefined {
// Stop auto-selecting after user makes explicit choice
if (wasSetterInvoked) {
return;
}
const savedWalletNameAndAddress = localStorage.getItem(STORAGE_KEY);
if (
!savedWalletNameAndAddress ||
typeof savedWalletNameAndAddress !== "string"
) {
return;
}
const [savedWalletName, savedAccountAddress] =
savedWalletNameAndAddress.split(":");
if (!savedWalletName || !savedAccountAddress) {
return;
}
// Find matching wallet and account in current connected wallets
for (const wallet of wallets) {
if (wallet.name === savedWalletName) {
for (const account of wallet.accounts) {
if (account.address === savedAccountAddress) {
return { account, wallet };
}
}
}
}
}
export function SelectedWalletAccountContextProvider({
children,
}: {
children: React.ReactNode;
}) {
const wallets = useWallets();
const [selectedWalletAccount, setSelectedWalletAccountInternal] =
useState<SelectedWalletAccountState>(
() => getSavedWalletAccountAndWallet(wallets)?.account
);
const [selectedWallet, setSelectedWalletInternal] = useState<
UiWallet | undefined
>(() => getSavedWalletAccountAndWallet(wallets)?.wallet);
// Wrapper that saves selection to localStorage
const setSelectedWalletAccount: React.Dispatch<
React.SetStateAction<SelectedWalletAccountState>
> = (setStateAction) => {
setSelectedWalletAccountInternal((prevSelectedWalletAccount) => {
wasSetterInvoked = true;
const nextWalletAccount =
typeof setStateAction === "function"
? setStateAction(prevSelectedWalletAccount)
: setStateAction;
const accountKey = nextWalletAccount
? getUiWalletAccountStorageKey(nextWalletAccount)
: undefined;
if (accountKey) {
localStorage.setItem(STORAGE_KEY, accountKey);
} else {
localStorage.removeItem(STORAGE_KEY);
}
return nextWalletAccount;
});
};
// Restore saved wallet on mount
useEffect(() => {
const savedWalletAccount = getSavedWalletAccountAndWallet(wallets)?.account;
const savedWallet = getSavedWalletAccountAndWallet(wallets)?.wallet;
if (savedWalletAccount && savedWallet) {
setSelectedWalletAccountInternal(savedWalletAccount);
setSelectedWalletInternal(savedWallet);
}
}, [wallets]);
// Keep selected account synchronized with connected wallets
const walletAccount = useMemo(() => {
if (selectedWalletAccount) {
for (const uiWallet of wallets) {
for (const uiWalletAccount of uiWallet.accounts) {
if (uiWalletAccountsAreSame(selectedWalletAccount, uiWalletAccount)) {
return uiWalletAccount;
}
}
// If selected account's wallet is connected, use its first account
if (
uiWalletAccountBelongsToUiWallet(selectedWalletAccount, uiWallet) &&
uiWallet.accounts[0]
) {
return uiWallet.accounts[0];
}
}
}
}, [selectedWalletAccount, wallets]);
const wallet = useMemo(() => {
if (selectedWallet) {
for (const uiWallet of wallets) {
if (uiWallet.name === selectedWallet.name) {
return uiWallet;
}
}
}
}, [selectedWallet, wallets]);
// Clear selection if wallet disconnects
useEffect(() => {
if (selectedWalletAccount && !walletAccount) {
setSelectedWalletAccountInternal(undefined);
}
}, [selectedWalletAccount, walletAccount]);
return (
<SelectedWalletAccountContext.Provider
value={useMemo(
() => [walletAccount, setSelectedWalletAccount, wallet],
[walletAccount, wallet]
)}
>
{children}
</SelectedWalletAccountContext.Provider>
);
}
Once our context is set up we can import and use it like so:
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { SelectedWalletAccountContextProvider } from "./context/SelectedWalletAccountContextProvider.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<SelectedWalletAccountContextProvider>
<App />
</SelectedWalletAccountContextProvider>
</StrictMode>
);
Wallet List
With context set up to track wallet state, we can build the UI. Users need a way to see available wallets and connect them to our app. The Wallet Standard exposes all compatible browser wallets, so we'll create a dialog that lists them. We'll start with the wallet list item.
// features/auth/components/wallet_list_item.tsx
import { Button } from "@/components/ui/button"
import { SelectedWalletAccountContext } from "@/context/SelectedWalletAccountContext";
import { useConnect, type UiWallet } from "@wallet-standard/react-core";
import { useContext } from "react";
type Props = {
wallet: UiWallet;
setOpen: () => void;
};
export const WalletListItem = ({ wallet, setOpen }: Props) => {
const [isConnecting, connect] = useConnect(wallet);
const [_, setSelectedWalletAccount] = useContext(
SelectedWalletAccountContext
);
const handleConnect = () => {
try {
// Connect returns array of accounts, we select the first one
connect().then((res) => {
setSelectedWalletAccount(res[0]);
});
} catch (error) {
console.error("Failed to connect:", error);
} finally {
setOpen();
}
};
if (!wallet) return;
return (
<li>
<Button
variant="outline"
disabled={isConnecting}
onClick={handleConnect}
className="flex flex-row p-4 items-center justify-start gap-3 w-full"
>
<img alt="wallet logo" src={wallet.icon} className="w-6 h-6" />
<p className="text-lg font-semibold">{wallet.name}</p>
</Button>
</li>
);
};
One thing to note is that while we can fetch all wallets connected to the browser, you often only want the ones that support the chain your application is based on. To do this we can create a hook to fetch and filter these wallets for us. You can extend this to work for multiple chains if need be. Extracting this logic into a custom hook is production-grade because it encapsulates the filtering logic in a reusable, testable unit. It makes your components cleaner by separating concerns, and if you need to add more complex filtering logic later or support multiple chains, you only need to modify this one hook rather than hunting through multiple components.
// features/auth/hooks/useFilteredWallets.tsx
import { useWallets } from "@wallet-standard/react-core";
export function useFilteredWallets() {
const wallets = useWallets();
// Filter wallets that support Solana chains
const solanaWallets = wallets.filter((uiWallet) =>
uiWallet.chains.some((chain) => chain.startsWith("solana:"))
);
return solanaWallets;
}
Now we can implement the actual wallet list to render all connected wallets that match our base chain.
// features/auth/components/wallet_list.tsx
import { Button } from "@/components/ui/button";
import {
DialogHeader,
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { WalletListItem } from "./wallet_list_item";
import { useFilteredWallets } from "../hooks/useFilteredWallets";
import { useState } from "react";
export const WalletList = () => {
const [open, setOpen] = useState(false);
const solanaWallets = useFilteredWallets();
return (
<Dialog open={open} onOpenChange={setOpen}>
<form>
<DialogTrigger asChild>
<Button variant="outline">View Wallets</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-106.25">
<DialogHeader>
<DialogTitle>Select Wallet</DialogTitle>
<DialogDescription>
Select the wallet you want to connect to Titan.
</DialogDescription>
</DialogHeader>
<ul className="flex flex-col gap-2">
{solanaWallets.map((wallet, idx) => (
<WalletListItem
key={idx}
wallet={wallet}
setOpen={() => setOpen(false)}
/>
))}
</ul>
</DialogContent>
</form>
</Dialog>
);
};
Sign-In Button
Once a wallet is connected, we need to authenticate the user. This is where the full flow comes together: request nonce, sign it, send to backend. This is just going to be a simple button that triggers the auth flow.
The first thing that will happen on click is that we request a nonce from our backend. Immediately after we get the nonce we use the useSignIn hook to sign our message. We then extract the results we need and send them in our login request.
// features/auth/components/auth_buttons.tsx
import { useSignIn } from "@solana/react";
import { Button } from "@/components/ui/button";
import { requestNonce } from "../api/requestNonce";
import { loginWithWallet } from "../api/loginWithWallet";
import type { UiWallet } from "@wallet-standard/react-core";
type SignInProps = {
address: string;
wallet: UiWallet;
};
export const SignInButton = ({ wallet, address }: SignInProps) => {
const signIn = useSignIn(wallet);
if (!wallet) return;
const handleSignIn = async () => {
try {
// Request nonce from backend
const res = await requestNonce(address);
// Sign the message with wallet's private key
const { account, signedMessage, signature } = await signIn({
nonce: res.nonce,
domain: import.meta.env.VITE_APP_DOMAIN || "localhost:5173",
});
// Send signed data to backend for verification
const loginResponse = await loginWithWallet({
address: account.address,
signatureBase58: signature,
message: signedMessage,
nonce: res.nonce,
});
// Store access token (you might want to use a state management solution)
localStorage.setItem('access_token', loginResponse.access_token);
console.log('Successfully authenticated!');
} catch (error) {
console.error("Failed to sign in:", error);
// You might want to show this error to the user via a toast or alert
}
};
return <Button onClick={handleSignIn}>Sign In</Button>;
};
Our finished App.tsx looks like this.
// App.tsx
import { useContext } from "react";
import { WalletList } from "./features/auth/components/wallet_list";
import { SelectedWalletAccountContext } from "./context/SelectedWalletAccountContext";
import { SignInButton } from "./features/auth/components/auth_buttons";
function App() {
const [selectedWalletAccount, _, selectedWallet] = useContext(
SelectedWalletAccountContext
);
return (
<div className="flex gap-3 min-h-svh flex-col items-center justify-center">
<div>
<p className="text-lg font-semibold">
Active account: {selectedWalletAccount?.address || 'None'}
</p>
</div>
<WalletList />
{selectedWalletAccount && selectedWallet && (
<SignInButton
address={selectedWalletAccount.address}
wallet={selectedWallet}
/>
)}
</div>
);
}
export default App;
Frontend Summary
That wraps up the frontend implementation. We've built a wallet connection system that integrates with the Wallet Standard to support any compatible Solana wallet, not just Phantom. The context provider manages wallet selection persistence across sessions, while the sign-in flow requests a nonce from the backend, prompts the user to sign it with their wallet, and sends the signed message back for verification. The private key never leaves the user's wallet, ensuring security throughout the process.
Conclusion
We've implemented a complete wallet-based authentication system for Solana. The backend generates unique nonces with Redis-backed storage, creates SIWS-compliant challenge messages, verifies signatures using Solana's cryptographic primitives, and manages JWT token generation after successful verification. The frontend leverages the Wallet Standard to support multiple wallet providers and handles the signing flow securely within the user's wallet extension.
This auth pattern is fundamentally different from traditional username/password authentication because the user proves ownership of their wallet through cryptographic signatures rather than shared secrets. The nonce prevents replay attacks, the standardized message format prevents phishing, and the time-limited Redis storage ensures old challenges can't be reused. You can extend this system by adding additional user data to the JWT payload, implementing token refresh logic, or supporting multiple blockchain networks with minimal modifications to the core flow.
Top comments (0)