Skip to main content
This guide explains how to implement wallet-based authentication using message signing and JSON Web Tokens (JWTs). Users sign a timestamped message with their Phantom wallet, the server verifies the signature, and then issues a JWT for authenticated requests.

Overview

This authentication approach uses cryptographic message signing instead of passwords. The client signs a message with a timestamp, the backend verifies the signature, checks the timestamp, and returns a JWT. No challenge storage or session state is required.

Key components

  • Message signing: User signs a timestamped message with their Phantom wallet
  • Signature verification: Server verifies the signature using the wallet’s public key
  • User management: Server locates or creates a user record for the wallet
  • JWT issuance: Server returns a token for authenticated API requests
Timestamp reliability: Client-side clocks may drift, especially on mobile devices. For production use, consider fetching timestamps from the server or using the Date header from API responses.

Authentication flow

Backend implementation

1. Dependencies

npm install express jsonwebtoken @solana/web3.js tweetnacl bs58

2. Verification service

Create 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;
  }
}

3. Authentication route

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;

4. Database schema (Prisma)

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_model

5. JWT authentication middleware

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 {
  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' });
  }
}

6. Protected route example

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;

Client implementation

1. Dependencies

npm install @phantom/react-sdk axios

2. API client

Create 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}`,
    },
  });
}

3. React authentication context

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>
  );
}

Security considerations

1. Timestamp validation

Messages include timestamps to prevent replay attacks. The server checks timestamps are recent and rejects older signed messages.
// 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');
}

2. Message format validation

The server checks the message matches the expected structure so signatures cannot be reused in different contexts.

3. JWT token security

For production applications, use asymmetric signing algorithms such as PS256 or RS256. These separate public and private keys, improving key management and security.
Generate RSA key pair:
# Generate private key
openssl genrsa -out private-key.pem 2048

# Extract public key
openssl rsa -in private-key.pem -pubout -out public-key.pem
Example .env:
JWT_PRIVATE_KEY_PATH=./private-key.pem
JWT_PUBLIC_KEY_PATH=./public-key.pem
NODE_ENV=production
Sign and verify with RS256:
import jwt from 'jsonwebtoken';
import fs from 'fs';

// Load keys
const privateKey = fs.readFileSync(process.env.JWT_PRIVATE_KEY_PATH, 'utf8');
const publicKey = fs.readFileSync(process.env.JWT_PUBLIC_KEY_PATH, 'utf8');

// Sign token with private key
const token = jwt.sign(
  {
    userId: user.id,
    walletAddress,
    iat: Math.floor(Date.now() / 1000)
  },
  privateKey,
  { algorithm: 'RS256', expiresIn: '24h' }
);

// Verify token with public key
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Using HS256 (simpler but less secure)

If you choose to use symmetric signing (HS256), store JWT_SECRET in environment variables:
# Generate secure secret
openssl rand -base64 32
Example .env:
JWT_SECRET=your_generated_secret_here
NODE_ENV=production
Sign and verify with HS256:
jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });

4. HTTPS required

Always use HTTPS in production environments.
// In production
if (process.env.NODE_ENV === 'production' && req.protocol !== 'https') {
  return res.redirect('https://' + req.headers.host + req.url);
}

5. Rate limiting

Protect your authentication route from abuse:
npm install express-rate-limit
import 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) => {
  // ...
});

6. Input validation

Always validate wallet addresses.
import { PublicKey } from '@solana/web3.js';

function isValidSolanaAddress(address: string): boolean {
  try {
    new PublicKey(address);
    return true;
  } catch {
    return false;
  }
}

7. Error handling

Avoid exposing internal errors in API responses.
// 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

Advantages

Security

  • Prevents replay attacks
  • Ties signatures to specific wallet addresses and timestamps
  • Uses cryptographic verification rather than passwords
  • Requires no server-side sessions

Environment setup

Backend (.env)

# 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

Frontend (.env.local)

# API URL
NEXT_PUBLIC_API_URL=https://api.yourdomain.com

# Phantom SDK
NEXT_PUBLIC_PHANTOM_CLIENT_ID=your_phantom_client_id

Testing

Manual tests with cURL

# 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"

Troubleshooting

”Invalid signature” error

  • Ensure the message format exactly matches what the server expects
  • Verify the wallet address in the message matches the signing wallet
  • Check signature encoding (base58)

“Invalid or expired timestamp” error

  • Ensure the client and server clocks are synchronized
  • Check the timestamp is ISO 8601
  • Check the timestamp falls within a 5-minute window

”No token provided” error

  • Ensure Authorization: Bearer <token> header is included
  • Verify the token has not expired