> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phantom.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Wallet authentication with JWTs

> Implement secure wallet-based authentication with message signing and JWT generation

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

<Note>
  **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.
</Note>

## Authentication flow

```mermaid theme={null}
sequenceDiagram
    participant Client
    participant API
    participant Database

    Note over Client: 1. Generate Message<br/>(with timestamp)
    Note over Client: 2. Sign Message<br/>(via Phantom Wallet)
    
    Client->>API: 3. Submit Signature<br/>POST /auth/authenticate
    Note right of Client: { walletAddress,<br/>signature,<br/>message,<br/>timestamp }
    
    Note over API: 4. Verify Signature<br/>(crypto verification)
    Note over API: 5. Check Timestamp<br/>(prevent replay attacks)
    
    API->>Database: 6. Find or Create User
    Database-->>API: User data
    
    API-->>Client: 7. Return JWT
    Note left of API: { token,<br/>walletAddress,<br/>userId,<br/>isNewUser }
```

## Backend implementation

### 1. Dependencies

```bash theme={null}
npm install express jsonwebtoken @solana/web3.js tweetnacl bs58
```

### 2. Verification service

Create `services/wallet-auth.ts`:

```typescript theme={null}
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`:

```typescript theme={null}
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`:

```prisma theme={null}
model User {
  id            String   @id @default(cuid())
  walletAddress String   @unique
  createdAt     DateTime @default(now())
  lastLoginAt   DateTime @default(now())

  @@index([walletAddress])
}
```

Run migration:

```bash theme={null}
npx prisma migrate dev --name add_user_model
```

### 5. JWT authentication middleware

Create `middleware/auth.ts`:

```typescript theme={null}
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

```typescript theme={null}
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

```bash theme={null}
npm install @phantom/react-sdk axios
```

### 2. API client

Create `lib/api.ts`:

```typescript theme={null}
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`:

```typescript theme={null}
'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.

```typescript theme={null}
// 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

<Warning>
  For production applications, use asymmetric signing algorithms such as **PS256** or **RS256**. These separate public and private keys, improving key management and security.
</Warning>

#### Using RS256 (recommended)

Generate RSA key pair:

```bash theme={null}
# 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`:

```bash theme={null}
JWT_PRIVATE_KEY_PATH=./private-key.pem
JWT_PUBLIC_KEY_PATH=./public-key.pem
NODE_ENV=production
```

Sign and verify with RS256:

```typescript theme={null}
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:

```bash theme={null}
# Generate secure secret
openssl rand -base64 32
```

Example `.env`:

```bash theme={null}
JWT_SECRET=your_generated_secret_here
NODE_ENV=production
```

Sign and verify with HS256:

```typescript theme={null}
jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });
```

### 4. HTTPS required

Always use HTTPS in production environments.

```typescript theme={null}
// 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:

```bash theme={null}
npm install express-rate-limit
```

```typescript theme={null}
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.

```typescript theme={null}
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.

```typescript theme={null}
// 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)

```bash theme={null}
# 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)

```bash theme={null}
# 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

```bash theme={null}
# 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
