Complete guide for implementing wallet authentication with message signing and JWT generation.
This authentication system uses cryptographic message signing to verify wallet ownership without requiring passwords. The flow generates a challenge, has the user sign it with their wallet, and returns a JWT token for authenticated API requests.
- Challenge Generation: Server creates a unique message for the user to sign
- Message Signing: User signs the message with their private key via Phantom wallet
- Signature Verification: Server verifies the signature matches the wallet's public key
- JWT Issuance: Server issues a JWT token for subsequent authenticated requests
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ API │ │ Database │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Request Challenge │ │
├────────────────────────────>│ │
│ POST /auth/challenge │ │
│ { walletAddress } │ │
│ │ 2. Store Challenge │
│ ├────────────────────────────>│
│ │ │
│ 3. Return Challenge │ │
│<────────────────────────────┤ │
│ { message, challenge } │ │
│ │ │
│ 4. Sign Message │ │
│ (via Phantom Wallet) │ │
│ │ │
│ 5. Submit Signature │ │
├────────────────────────────>│ │
│ POST /auth/verify │ │
│ { walletAddress, │ 6. Verify Challenge │
│ signature, ├────────────────────────────>│
│ challenge } │ │
│ │ 7. Mark as Used │
│ │<────────────────────────────┤
│ │ │
│ │ 8. Verify Signature │
│ │ (crypto verification) │
│ │ │
│ 9. Return JWT │ │
│<────────────────────────────┤ │
│ { token, walletAddress } │ │
│ │ │
npm install express jsonwebtoken @solana/web3.js tweetnacl bs58Create services/wallet-auth.ts:
import { PublicKey } from '@solana/web3.js';
import { sign } from 'tweetnacl';
import bs58 from 'bs58';
/**
* Verify a wallet signature for authentication
* @param message - The original message that was signed
* @param signature - The signature in base58 format
* @param walletAddress - The wallet address that signed the message
* @returns boolean indicating if the signature is valid
*/
export function verifyWalletSignature(
message: string,
signature: string,
walletAddress: string
): boolean {
try {
// Convert message to Uint8Array
const messageBytes = new TextEncoder().encode(message);
// Decode signature from base58
const signatureBytes = bs58.decode(signature);
// Convert wallet address to PublicKey
const publicKey = new PublicKey(walletAddress);
const publicKeyBytes = publicKey.toBytes();
// Verify signature using TweetNaCl
const isValid = sign.detached.verify(
messageBytes,
signatureBytes,
publicKeyBytes
);
return isValid;
} catch (error) {
console.error('Error verifying wallet signature:', error);
return false;
}
}
/**
* Generate a random challenge message for wallet signing
* @returns object with challenge and message
*/
export function generateAuthChallenge() {
const challenge = Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
const message = `Please sign this message to authenticate with Phantom Connect: ${challenge}`;
return { challenge, message };
}Create routes/auth.ts:
import { Router } from 'express';
import { PublicKey } from '@solana/web3.js';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { prisma } from '../lib/prisma';
import { verifyWalletSignature } from '../services/wallet-auth';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
/**
* POST /auth/challenge
* Generate authentication challenge for wallet signing
*/
router.post('/challenge', async (req, res) => {
try {
const { walletAddress } = req.body;
if (!walletAddress || typeof walletAddress !== 'string') {
return res.status(400).json({ error: 'walletAddress is required' });
}
// Validate wallet address format
try {
new PublicKey(walletAddress);
} catch (error) {
return res.status(400).json({ error: 'Invalid wallet address format' });
}
// Generate random challenge (64 hex characters)
const challenge = crypto.randomBytes(32).toString('hex');
const message = `Please sign this message to authenticate: ${challenge}`;
// Store challenge temporarily (expires in 10 minutes)
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
await prisma.authChallenge.create({
data: {
walletAddress,
challenge,
message,
expiresAt
}
});
res.json({
success: true,
message,
challenge
});
} catch (error) {
console.error('Challenge generation error:', error);
res.status(500).json({
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
/**
* POST /auth/verify
* Verify wallet signature and issue JWT token
*/
router.post('/verify', async (req, res) => {
try {
const { walletAddress, signature, challenge } = req.body;
if (!walletAddress || !signature || !challenge) {
return res.status(400).json({
error: 'walletAddress, signature, and challenge are required'
});
}
// Find and verify challenge
const authChallenge = await prisma.authChallenge.findFirst({
where: {
walletAddress,
challenge,
expiresAt: {
gt: new Date() // Challenge not expired
},
used: false // Challenge not already used
}
});
if (!authChallenge) {
return res.status(401).json({ error: 'Invalid or expired challenge' });
}
// Verify signature using TweetNaCl
const isValidSignature = verifyWalletSignature(
authChallenge.message,
signature,
walletAddress
);
if (!isValidSignature) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Mark challenge as used (prevent replay attacks)
await prisma.authChallenge.update({
where: { id: authChallenge.id },
data: { used: true }
});
// Generate JWT token
const token = jwt.sign(
{
walletAddress,
iat: Math.floor(Date.now() / 1000)
},
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
success: true,
token,
walletAddress
});
} catch (error) {
console.error('Verification error:', error);
res.status(500).json({
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
export default router;Add to your schema.prisma:
model AuthChallenge {
id String @id @default(cuid())
walletAddress String
challenge String
message String
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
@@index([walletAddress, challenge])
@@index([expiresAt])
}Create middleware/auth.ts:
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
export interface AuthRequest extends Request {
walletAddress?: string;
}
export function requireAuth(
req: AuthRequest,
res: Response,
next: NextFunction
) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
const decoded = jwt.verify(token, JWT_SECRET) as {
walletAddress: string;
iat: number;
};
req.walletAddress = decoded.walletAddress;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}import { Router } from 'express';
import { requireAuth, AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/profile', requireAuth, async (req: AuthRequest, res) => {
const walletAddress = req.walletAddress;
// Use walletAddress to fetch user data
res.json({
success: true,
walletAddress,
// ... other user data
});
});
export default router;npm install @phantom/react-sdk axiosCreate lib/api.ts:
import axios from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002';
const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
/**
* Request a challenge from the server
*/
export async function generateChallenge(walletAddress: string): Promise<{
success: boolean;
message: string;
challenge: string;
}> {
const response = await api.post('/api/auth/challenge', { walletAddress });
return response.data;
}
/**
* Verify the signature and get JWT token
*/
export async function verifySignature(
walletAddress: string,
signature: string,
challenge: string
): Promise<{
success: boolean;
token: string;
walletAddress: string;
}> {
const response = await api.post('/api/auth/verify', {
walletAddress,
signature,
challenge
});
return response.data;
}
/**
* Make authenticated API request
*/
export async function authenticatedRequest(
token: string,
endpoint: string,
method: 'GET' | 'POST' = 'GET',
data?: any
) {
return api({
method,
url: endpoint,
data,
headers: {
Authorization: `Bearer ${token}`,
},
});
}Create context/AuthContext.tsx:
'use client';
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type PropsWithChildren,
} from 'react';
import { useRouter } from 'next/navigation';
import {
NetworkId,
useConnect,
useDisconnect,
usePhantom,
useSolana,
} from '@phantom/react-sdk';
import { generateChallenge, verifySignature } from '@/lib/api';
interface AuthContextValue {
signIn: () => Promise<void>;
signOut: () => void;
isConnected: boolean;
walletAddress?: string;
isLoading: boolean;
token?: string | null;
session?: any;
}
const AuthContext = createContext<AuthContextValue>({
signIn: () => Promise.resolve(),
signOut: () => null,
isConnected: false,
walletAddress: undefined,
isLoading: false,
token: null,
session: null,
});
export function useSession() {
const value = useContext(AuthContext);
if (!value) {
throw new Error('useSession must be wrapped in a <SessionProvider />');
}
return value;
}
export function SessionProvider({ children }: PropsWithChildren) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [token, setToken] = useState<string | null>(null);
const [walletAddress, setWalletAddress] = useState<string>();
// Phantom SDK hooks
const { connect } = useConnect();
const { solana } = useSolana();
const { addresses, isConnected } = usePhantom();
const { disconnect } = useDisconnect();
/**
* Complete authentication handshake
*/
const completeAuthentication = useCallback(
async (walletAddr: string) => {
try {
console.log('Starting authentication for:', walletAddr);
// Step 1: Get challenge from server
const challengeResponse = await generateChallenge(walletAddr);
console.log('Challenge received:', challengeResponse.challenge);
// Step 2: Sign the challenge message with wallet
const signResult = await solana.signMessage(challengeResponse.message);
console.log('Message signed successfully');
// Step 3: Verify signature and get JWT token
const authResponse = await verifySignature(
walletAddr,
signResult.signature,
challengeResponse.challenge
);
console.log('Authentication successful!');
// Store JWT token
setToken(authResponse.token);
setWalletAddress(walletAddr);
// Optionally store in localStorage for persistence
localStorage.setItem('jwt_token', authResponse.token);
} catch (error) {
console.error('Authentication failed:', error);
// Clear state on error
setWalletAddress(undefined);
setToken(null);
localStorage.removeItem('jwt_token');
throw error;
} finally {
setIsLoading(false);
}
},
[signMessage]
);
/**
* Handle auto-connection from Phantom SDK
*/
useEffect(() => {
if (isConnected && !token && addresses.length > 0) {
console.log('Auto-connected wallet detected');
completeAuthentication(addresses[0].address).catch(console.error);
} else {
setIsLoading(false);
}
}, [isConnected, token, addresses, completeAuthentication]);
return (
<AuthContext.Provider
value={{
signIn: async () => {
try {
console.log('Starting wallet connection...');
// Connect wallet using Phantom SDK
const result = await connect();
const walletAddr = result.addresses[0]?.address;
if (!walletAddr) {
throw new Error('No wallet address returned');
}
// Complete authentication handshake
await completeAuthentication(walletAddr);
} catch (error) {
console.error('Sign in error:', error);
throw error;
}
},
signOut: async () => {
try {
console.log('Signing out...');
// Disconnect wallet
await disconnect();
setWalletAddress(undefined);
setToken(null);
// Clear stored token
localStorage.removeItem('jwt_token');
// Redirect to sign-in
router.push('/sign-in');
} catch (error) {
console.error('Sign out error:', error);
}
},
isConnected,
walletAddress,
isLoading,
token,
session: isConnected && token ? { user: { id: walletAddress } } : null,
}}
>
{children}
</AuthContext.Provider>
);
}- Challenges expire after 10 minutes to prevent replay attacks
- Used challenges are marked and cannot be reused
- Regular cleanup of expired challenges recommended
// Cleanup script (run periodically)
await prisma.authChallenge.deleteMany({
where: {
OR: [
{ expiresAt: { lt: new Date() } },
{
used: true,
createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) }
}
]
}
});- Store JWT_SECRET in environment variables
- Use strong, random secrets in production
- Set appropriate token expiration (24h recommended)
- Consider refresh token implementation for longer sessions
# Generate secure secret
openssl rand -base64 32Always use HTTPS in production to prevent man-in-the-middle attacks:
// In production
if (process.env.NODE_ENV === 'production' && req.protocol !== 'https') {
return res.redirect('https://' + req.headers.host + req.url);
}Implement rate limiting to prevent abuse:
import rateLimit from 'express-rate-limit';
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
message: 'Too many authentication attempts, please try again later'
});
router.post('/challenge', authLimiter, async (req, res) => {
// ...
});Always validate wallet addresses and signatures:
import { PublicKey } from '@solana/web3.js';
function isValidSolanaAddress(address: string): boolean {
try {
new PublicKey(address);
return true;
} catch {
return false;
}
}Don't leak sensitive information in error messages:
// Bad
res.status(500).json({ error: error.message });
// Good
res.status(500).json({ error: 'Authentication failed' });
console.error('Auth error:', error); // Log for debugging