Skip to content

Instantly share code, notes, and snippets.

@rafinskipg
Created October 23, 2025 08:11
Show Gist options
  • Select an option

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

Select an option

Save rafinskipg/d2535a354dd35e03af381528569533ac to your computer and use it in GitHub Desktop.
title description
Wallet Authentication with JWTs
Implementing wallet authentication with message signing and JWT generation

Wallet Authentication with JWTs

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

Overview

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.

Key Components

  • 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

Authentication Flow

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

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

2. Message Format Validation

The server validates the message format matches expected structure, prevents signature reuse across different contexts, and ties signature to specific wallet address and timestamp.

3. JWT Token Security

Store JWT_SECRET in environment variables:

# Generate secure secret
openssl rand -base64 32

Example .env:

JWT_SECRET=your_generated_secret_here
NODE_ENV=production

Set appropriate token expiration:

jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });

4. HTTPS Only

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

5. Rate Limiting

Implement rate limiting to prevent 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

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

Advantages

Security

  • 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

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 Test 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 message format exactly matches what server expects
  • Verify wallet address in message matches signing wallet
  • Check signature is properly base58 encoded

"Invalid or expired timestamp" error

  • Ensure client and server clocks are synchronized
  • Check timestamp is ISO 8601 format
  • Verify timestamp is within 5-minute window

"No token provided" error

  • Ensure Authorization header is set
  • Format must be: Bearer YOUR_TOKEN
  • Token must not be expired
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment