| title | description |
|---|---|
Wallet Authentication with JWTs |
Implementing wallet authentication with message signing and JWT generation |
Complete guide for implementing wallet authentication with direct message signing and JWT generation.
This authentication system uses cryptographic message signing to verify wallet ownership without requiring passwords or server-side challenge storage. The user signs a timestamped message with their wallet, the server verifies the signature, looks up or creates a user account, and returns a JWT token.
- Message Signing: User signs a timestamped message with their Phantom wallet
- Signature Verification: Server verifies the signature matches the wallet's public key
- User Management: Server looks up existing user or creates new one for the wallet
- JWT Issuance: Server issues a JWT token for subsequent authenticated requests
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ API │ │ Database │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Generate Message │ │
│ (with timestamp) │ │
│ │ │
│ 2. Sign Message │ │
│ (via Phantom Wallet) │ │
│ │ │
│ 3. Submit Signature │ │
├────────────────────────────>│ │
│ POST /auth/authenticate │ │
│ { walletAddress, │ │
│ signature, │ │
│ message, │ │
│ timestamp } │ │
│ │ │
│ │ 4. Verify Signature │
│ │ (crypto verification) │
│ │ │
│ │ 5. Check Timestamp │
│ │ (prevent replay attacks) │
│ │ │
│ │ 6. Find or Create User │
│ ├────────────────────────────>│
│ │<────────────────────────────┤
│ │ │
│ 7. Return JWT │ │
│<────────────────────────────┤ │
│ { token, walletAddress, │ │
│ userId, isNewUser } │ │
│ │ │
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 authentication message with timestamp
* @param walletAddress - The wallet address
* @param timestamp - ISO timestamp
* @returns formatted message for signing
*/
export function generateAuthMessage(walletAddress: string, timestamp: string): string {
return `Sign this message to authenticate with Phantom Connect\n\nWallet: ${walletAddress}\nTimestamp: ${timestamp}`;
}
/**
* Verify timestamp is recent (within 5 minutes)
* @param timestamp - ISO timestamp string
* @returns boolean indicating if timestamp is valid
*/
export function isValidTimestamp(timestamp: string): boolean {
try {
const messageTime = new Date(timestamp).getTime();
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
// Check timestamp is not in the future and not older than 5 minutes
return messageTime <= now && (now - messageTime) <= fiveMinutes;
} catch (error) {
return false;
}
}Create routes/auth.ts:
import { Router } from 'express';
import { PublicKey } from '@solana/web3.js';
import jwt from 'jsonwebtoken';
import { prisma } from '../lib/prisma';
import {
verifyWalletSignature,
generateAuthMessage,
isValidTimestamp
} from '../services/wallet-auth';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
/**
* POST /auth/authenticate
* Verify wallet signature and issue JWT token
*/
router.post('/authenticate', async (req, res) => {
try {
const { walletAddress, signature, message, timestamp } = req.body;
// Validate required fields
if (!walletAddress || !signature || !message || !timestamp) {
return res.status(400).json({
error: 'walletAddress, signature, message, and timestamp are required'
});
}
// Validate wallet address format
try {
new PublicKey(walletAddress);
} catch (error) {
return res.status(400).json({ error: 'Invalid wallet address format' });
}
// Verify timestamp is recent (prevents replay attacks)
if (!isValidTimestamp(timestamp)) {
return res.status(401).json({
error: 'Invalid or expired timestamp. Please try again.'
});
}
// Verify the message format matches expected structure
const expectedMessage = generateAuthMessage(walletAddress, timestamp);
if (message !== expectedMessage) {
return res.status(401).json({ error: 'Message format mismatch' });
}
// Verify signature using TweetNaCl
const isValidSignature = verifyWalletSignature(
message,
signature,
walletAddress
);
if (!isValidSignature) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Find or create user for this wallet
let user = await prisma.user.findUnique({
where: { walletAddress }
});
let isNewUser = false;
if (!user) {
user = await prisma.user.create({
data: {
walletAddress,
createdAt: new Date(),
lastLoginAt: new Date()
}
});
isNewUser = true;
} else {
// Update last login time
user = await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() }
});
}
// Generate JWT token
const token = jwt.sign(
{
userId: user.id,
walletAddress,
iat: Math.floor(Date.now() / 1000)
},
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
success: true,
token,
walletAddress,
userId: user.id,
isNewUser
});
} catch (error) {
console.error('Authentication error:', error);
res.status(500).json({
error: 'Internal server error'
});
}
});
export default router;Add to your schema.prisma:
model User {
id String @id @default(cuid())
walletAddress String @unique
createdAt DateTime @default(now())
lastLoginAt DateTime @default(now())
@@index([walletAddress])
}Run migration:
npx prisma migrate dev --name add_user_modelCreate 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 {
userId?: string;
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 {
userId: string;
walletAddress: string;
iat: number;
};
req.userId = decoded.userId;
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';
import { prisma } from '../lib/prisma';
const router = Router();
router.get('/profile', requireAuth, async (req: AuthRequest, res) => {
const user = await prisma.user.findUnique({
where: { id: req.userId }
});
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
success: true,
user: {
id: user.id,
walletAddress: user.walletAddress,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt
}
});
});
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',
},
});
/**
* Authenticate with wallet signature
*/
export async function authenticate(
walletAddress: string,
signature: string,
message: string,
timestamp: string
): Promise<{
success: boolean;
token: string;
walletAddress: string;
userId: string;
isNewUser: boolean;
}> {
const response = await api.post('/api/auth/authenticate', {
walletAddress,
signature,
message,
timestamp
});
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 {
useConnect,
useDisconnect,
usePhantom,
useSolana,
} from '@phantom/react-sdk';
import { authenticate } from '@/lib/api';
interface AuthContextValue {
signIn: () => Promise<void>;
signOut: () => void;
isConnected: boolean;
walletAddress?: string;
userId?: string;
isLoading: boolean;
token?: string | null;
session?: any;
}
const AuthContext = createContext<AuthContextValue>({
signIn: () => Promise.resolve(),
signOut: () => null,
isConnected: false,
walletAddress: undefined,
userId: 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;
}
/**
* Generate authentication message with timestamp
*/
function generateAuthMessage(walletAddress: string, timestamp: string): string {
return `Sign this message to authenticate with Phantom Connect\n\nWallet: ${walletAddress}\nTimestamp: ${timestamp}`;
}
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>();
const [userId, setUserId] = 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);
// Generate timestamp
const timestamp = new Date().toISOString();
// Generate message to sign
const message = generateAuthMessage(walletAddr, timestamp);
console.log('Message to sign:', message);
// Sign the message with wallet
const signResult = await solana.signMessage(message);
console.log('Message signed successfully');
// Authenticate with backend
const authResponse = await authenticate(
walletAddr,
signResult.signature,
message,
timestamp
);
console.log('Authentication successful!', {
isNewUser: authResponse.isNewUser
});
// Store JWT token and user info
setToken(authResponse.token);
setWalletAddress(walletAddr);
setUserId(authResponse.userId);
// Optionally store in localStorage for persistence
localStorage.setItem('jwt_token', authResponse.token);
localStorage.setItem('wallet_address', walletAddr);
localStorage.setItem('user_id', authResponse.userId);
} catch (error) {
console.error('Authentication failed:', error);
// Clear state on error
setWalletAddress(undefined);
setUserId(undefined);
setToken(null);
localStorage.removeItem('jwt_token');
localStorage.removeItem('wallet_address');
localStorage.removeItem('user_id');
throw error;
} finally {
setIsLoading(false);
}
},
[solana]
);
/**
* 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]);
/**
* Restore session from localStorage on mount
*/
useEffect(() => {
const storedToken = localStorage.getItem('jwt_token');
const storedWallet = localStorage.getItem('wallet_address');
const storedUserId = localStorage.getItem('user_id');
if (storedToken && storedWallet && storedUserId && !isConnected) {
setToken(storedToken);
setWalletAddress(storedWallet);
setUserId(storedUserId);
}
setIsLoading(false);
}, []);
return (
<AuthContext.Provider
value={{
signIn: async () => {
try {
setIsLoading(true);
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);
setIsLoading(false);
throw error;
}
},
signOut: async () => {
try {
console.log('Signing out...');
// Disconnect wallet
await disconnect();
setWalletAddress(undefined);
setUserId(undefined);
setToken(null);
// Clear stored data
localStorage.removeItem('jwt_token');
localStorage.removeItem('wallet_address');
localStorage.removeItem('user_id');
// Redirect to sign-in
router.push('/sign-in');
} catch (error) {
console.error('Sign out error:', error);
}
},
isConnected,
walletAddress,
userId,
isLoading,
token,
session: isConnected && token ? { user: { id: userId } } : null,
}}
>
{children}
</AuthContext.Provider>
);
}Messages include timestamps to prevent replay attacks. The server validates timestamps are within 5 minutes and prevents old signed messages from being reused.
// Client generates fresh timestamp for each auth attempt
const timestamp = new Date().toISOString();
// Server validates recency
const fiveMinutes = 5 * 60 * 1000;
if (now - messageTime > fiveMinutes) {
throw new Error('Expired timestamp');
}The server validates the message format matches expected structure, prevents signature reuse across different contexts, and ties signature to specific wallet address and timestamp.
Store JWT_SECRET in environment variables:
# Generate secure secret
openssl rand -base64 32Example .env:
JWT_SECRET=your_generated_secret_here
NODE_ENV=production
Set appropriate token expiration:
jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });Always use HTTPS in production:
// 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:
npm install express-rate-limitimport rateLimit from 'express-rate-limit';
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 requests per window
message: 'Too many authentication attempts, please try again later'
});
router.post('/authenticate', authLimiter, async (req, res) => {
// ...
});Always validate wallet addresses:
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:
// Bad - exposes internal details
res.status(500).json({ error: error.message });
// Good - generic error message
res.status(500).json({ error: 'Authentication failed' });
console.error('Auth error:', error); // Log for debugging- Replay attack prevention: Timestamp validation prevents signature reuse
- Message uniqueness: Each authentication attempt uses unique timestamp
- Signature verification: Cryptographic proof of wallet ownership
- Stateless: No server-side session storage needed
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET=your_secure_secret_here
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
# Server
PORT=3002
NODE_ENV=production
# CORS (if needed)
ALLOWED_ORIGINS=https://yourdomain.com# API URL
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
# Phantom SDK
NEXT_PUBLIC_PHANTOM_CLIENT_ID=your_phantom_client_id# 1. Sign a message with your wallet (use Phantom)
# Get: walletAddress, signature, message, timestamp
# 2. Authenticate
curl -X POST http://localhost:3002/api/auth/authenticate \
-H "Content-Type: application/json" \
-d '{
"walletAddress": "YourWalletAddress",
"signature": "Base58Signature",
"message": "Sign this message to authenticate...",
"timestamp": "2025-10-23T12:00:00.000Z"
}'
# 3. Use token for authenticated requests
curl http://localhost:3002/api/profile \
-H "Authorization: Bearer YOUR_JWT_TOKEN"- Ensure message format exactly matches what server expects
- Verify wallet address in message matches signing wallet
- Check signature is properly base58 encoded
- Ensure client and server clocks are synchronized
- Check timestamp is ISO 8601 format
- Verify timestamp is within 5-minute window
- Ensure
Authorizationheader is set - Format must be:
Bearer YOUR_TOKEN - Token must not be expired