Skip to content

Instantly share code, notes, and snippets.

@rafinskipg
Created October 17, 2025 20:20
Show Gist options
  • Select an option

  • Save rafinskipg/3ab4721272de3d6c6af904196cc3b72b to your computer and use it in GitHub Desktop.

Select an option

Save rafinskipg/3ab4721272de3d6c6af904196cc3b72b to your computer and use it in GitHub Desktop.

Phantom Wallet Authentication Guide

Complete guide for implementing wallet authentication with message signing and JWT generation.


Overview

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.

Key Components

  • 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

Authentication Flow

┌──────────┐                  ┌──────────┐                  ┌──────────┐
│  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 }   │                             │
     │                             │                             │

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

3. Authentication Routes

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;

4. Database Schema (Prisma)

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

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

6. Protected Route Example

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;

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

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

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

Security Considerations

1. Challenge Expiration

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

2. JWT Token Security

  • 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 32

3. HTTPS Only

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

4. Rate Limiting

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) => {
  // ...
});

5. Input Validation

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

6. Error Handling

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment