Solana-Supabase Authentication

solanasupabaseauthenticationweb3nextjstypescript

Complete implementation of Solana wallet authentication using Supabase as the backend, with Next.js 14 and shadcn/ui components

Solana Authentication with Supabase

Note: This implementation is robust but may require additional production hardening, such as rate limiting and enhanced error handling.

This comprehensive guide demonstrates how to implement Solana wallet authentication using Supabase on the backend. The system combines the security of cryptographic signatures with the reliability of Supabase's authentication infrastructure, built with Next.js 14 and shadcn/ui components.

System Architecture Overview

The authentication system is designed with modularity and security in mind, separating concerns across distinct layers:

    1. Wallet Integration Layer: Handles Solana wallet connections using the @solana/wallet-adapter library
    1. Authentication State Management: Uses React Context and a reducer for managing authentication state
    1. Backend API Layer: Provides secure endpoints for nonce generation and signature verification
    1. Database Integration: Utilizes Supabase for user storage and custom claims management
    1. Route Protection: Implements middleware to secure API routes

This architecture ensures secure cryptographic operations, efficient state management, and a scalable backend while prioritizing user experience.

Wallet Provider Integration

The SolanaProvider component establishes the foundation for wallet connectivity, integrating multiple wallet adapters and handling connection errors gracefully.

// context/WalletProvider
import { FC, ReactNode, useCallback, useMemo } from 'react';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { PhantomWalletAdapter, SolflareWalletAdapter } from '@solana/wallet-adapter-wallets';
import { WalletError } from '@solana/wallet-adapter-base';
import { toast } from '@/components/ui/toast';

export const SolanaProvider: FC<{ children: ReactNode }> = ({ children }) => {
    const wallets = useMemo(
        () => [
            new PhantomWalletAdapter(),
            new SolflareWalletAdapter(),
        ],
        []
    );

    const onError = useCallback(
        (error: WalletError) => {
            toast({
                title: "Wallet Error",
                description: error.message,
            });
        },
        []
    );

    return (
        <ConnectionProvider endpoint={process.env.NEXT_PUBLIC_RPC!}>
            <WalletProvider autoConnect wallets={wallets} onError={onError}>
                {children}
            </WalletProvider>
        </ConnectionProvider>
    );
};

The SolanaProvider leverages the @solana/wallet-adapter-react library to manage wallet connections. It initializes support for Phantom and Solflare wallets, which are instantiated in a useMemo hook to prevent unnecessary re-instantiation. The ConnectionProvider establishes a connection to the Solana network using a configurable RPC endpoint, ensuring reliable blockchain interactions. The WalletProvider enables automatic reconnection for returning users (autoConnect) and includes an onError callback to display user-friendly error messages via a toast notification system.

Authentication State Management

The AuthProvider manages the authentication lifecycle, handling wallet connections, user sessions, and state persistence using a React Context and reducer.

// context/AuthProvider
'use client';

import { createContext, useCallback, useContext, useEffect, useReducer, ReactNode } from 'react';
import { useWallet } from '@solana/wallet-adapter-react';
import { useCookies } from 'react-cookie';
import { SignMessage } from '@/lib/signMessage';
import bs58 from 'bs58';

interface User {
    id: string;
    address: string;
    avatar_url: string | null;
    billing_address: any | null;
    email: string | null;
    full_name: string | null;
    last_auth: string | null;
    last_auth_status: string | null;
    nonce: string | null;
    payment_method: any | null;
}

interface AuthState {
    token: string | null;
    user: User | null;
    loading: boolean;
    connectedWallet: string | null;
}

type AuthAction =
    | { type: 'SIGN_IN'; token: string; user: User; connectedWallet: string }
    | { type: 'SIGN_OUT' };

const initialState: AuthState = {
    token: null,
    user: null,
    loading: true,
    connectedWallet: null,
};

function authReducer(state: AuthState, action: AuthAction): AuthState {
    switch (action.type) {
        case 'SIGN_IN':
            return {
                ...state,
                token: action.token,
                user: action.user,
                loading: false,
                connectedWallet: action.connectedWallet,
            };
        case 'SIGN_OUT':
            return {
                ...state,
                token: null,
                user: null,
                loading: false,
                connectedWallet: null,
            };
        default:
            return state;
    }
}

const AuthContext = createContext<{
    token: string | null;
    user: User | null;
    loading: boolean;
    connectedWallet: string | null;
    signIn: () => Promise<void>;
    signOut: () => Promise<void>;
} | undefined>(undefined);

export const AuthProvider = ({ children }: { children: ReactNode }) => {
    const [state, dispatch] = useReducer(authReducer, initialState);
    const { publicKey, signMessage, disconnect, wallet } = useWallet();
    const [cookies, setCookie, removeCookie] = useCookies(['token']);

    const signIn = useCallback(async () => {
        if (!signMessage || !publicKey || state.user) return;

        try {
            const nonceResponse = await fetch('/api/auth/nonce', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ address: publicKey.toBase58() }),
            });
            if (!nonceResponse.ok) throw new Error(Failed to fetch nonce: ${nonceResponse.statusText});
            const { nonce } = await nonceResponse.json();

            const message = new SignMessage({ publicKey: publicKey.toBase58(), statement: 'Sign in', nonce });
            const data = new TextEncoder().encode(message.prepare());
            const signature = await signMessage(data);
            const serializedSignature = bs58.encode(signature);

            const signInResponse = await fetch('/api/auth/login', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ message: JSON.stringify(message), signature: serializedSignature }),
            });
            if (!signInResponse.ok) throw new Error(Failed to sign in: ${signInResponse.statusText});

            const { token, user } = await signInResponse.json();
            setCookie('token', token, { path: '/' });
            const connectedWallet = wallet?.adapter.name || 'Unknown Wallet';

            dispatch({ type: 'SIGN_IN', token, user, connectedWallet });
        } catch (error: any) {
            await disconnect();
            console.error("Sign in error:", error.message);
        }
    }, [publicKey, signMessage, wallet, state.user, setCookie, disconnect]);

    const signOut = useCallback(async () => {
        if (!publicKey || !state.token) return;

        try {
            const logoutResponse = await fetch('/api/auth/logout', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
            });
            if (!logoutResponse.ok) throw new Error(Failed to logout: ${logoutResponse.statusText});

            await disconnect();
            removeCookie('token', { path: '/' });
            dispatch({ type: 'SIGN_OUT' });
        } catch (error: any) {
            console.error("Sign out error:", error.message);
        }
    }, [publicKey, state.token, disconnect, removeCookie]);

    const getUser = useCallback(async (token: string) => {
        if (!token || !publicKey || state.user) return;

        try {
            const response = await fetch(/api/user?address=${publicKey.toBase58()}, {
                method: 'GET',
                headers: { 'Authorization': Bearer ${token} },
            });

            if (!response.ok) throw new Error(Failed to fetch user info: ${response.statusText});

            const data = await response.json();
            const connectedWallet = wallet?.adapter.name || 'Unknown Wallet';

            dispatch({ type: 'SIGN_IN', token, user: data.user, connectedWallet });
        } catch (error: any) {
            console.error("Failed to load user info:", error.message);
            await disconnect();
            dispatch({ type: 'SIGN_OUT' });
        }
    }, [publicKey, wallet, state.user, disconnect]);

    const checkAuth = useCallback(async () => {
        try {
            const token = cookies.token;
            if (token && publicKey) {
                await getUser(token);
            } else if (publicKey) {
                await signIn();
            }
        } catch (error) {
            console.error('Error checking authentication:', error);
            dispatch({ type: 'SIGN_OUT' });
        }
    }, [getUser, signIn, publicKey, cookies]);

    useEffect(() => {
        checkAuth();
    }, [checkAuth]);

    const contextValue = useMemo(() => ({
        ...state,
        signIn,
        signOut,
    }), [state, signIn, signOut]);

    return (
        <AuthContext.Provider value={contextValue}>
            {children}
        </AuthContext.Provider>
    );
};

export const useAuth = () => {
    const context = useContext(AuthContext);
    if (!context) throw new Error('useAuth must be used within an AuthProvider');
    return context;
};

The AuthProvider is the core of the authentication state management system, using a React Context to provide authentication state and methods (signIn, signOut) to child components. It employs a reducer (authReducer) to manage state transitions for signing in and out, ensuring predictable state updates. The signIn function orchestrates the authentication flow: it fetches a nonce from the backend, signs a message with the user's wallet, and sends the signature for verification, storing the resulting JWT token in a cookie. The signOut function clears the session by calling the logout endpoint, disconnecting the wallet, and removing the token. The getUser function retrieves user data for session restoration, while checkAuth ensures authentication state is validated on page load. The use of useCallback and useMemo optimizes performance by preventing unnecessary re-renders.

Application Layout Configuration

The root layout sets up the provider hierarchy, ensuring all components have access to wallet and authentication contexts.

// app/layout.tsx
import { Inter } from 'next/font/google';
import { ThemeProvider } from '@/components/theme-provider';
import { CookiesProvider, getCookies } from 'react-cookie';
import { SolanaProvider } from '@/context/WalletProvider';
import { AuthProvider } from '@/context/AuthProvider';
import { Toaster } from '@/components/ui/toaster';

const inter = Inter({ subsets: ['latin'] });

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const cookies = getCookies();
  const token = cookies.get('token');

  return (
    <html lang="en">
      <body className={inter.className}>
        <ThemeProvider attribute="class" defaultTheme="system">
          <SolanaProvider>
            <CookiesProvider>
              <AuthProvider initToken={token}>
                {children}
                <Toaster />
              </AuthProvider>
            </CookiesProvider>
          </SolanaProvider>
        </ThemeProvider>
      </body>
    </html>
  );
}

The RootLayout component establishes the provider hierarchy for the application, ensuring that wallet connectivity (SolanaProvider), cookie management (CookiesProvider), and authentication state (AuthProvider) are available to all child components. The Inter font from Next.js enhances typography, while the ThemeProvider enables system-based theme switching (light/dark mode). The Toaster component integrates shadcn/ui's toast notifications for user feedback. The layout retrieves the initial token from cookies to support session persistence, passing it to AuthProvider for authentication state initialization.

User Interface Components

The wallet components provide a user-friendly interface for connecting and disconnecting wallets, abstracting complex authentication logic.

// components/Wallet.tsx
import { useAuth } from '@/context/AuthProvider';
import { Button } from '@/components/ui/button';
import WalletModal from '@/components/WalletModal';

export default function Wallet() {
    const { signOut, token } = useAuth();
    return (
        <div className="border-t px-4 py-4">
            {token ? (
                <div className="flex flex-col items-center">
                    <Button onClick={signOut} className="w-full">
                        Disconnect Wallet
                    </Button>
                </div>
            ) : (
                <WalletModal />
            )}
        </div>
    );
}

The Wallet component provides a simple interface for wallet management, conditionally rendering based on the authentication state. If a user is authenticated (i.e., a token exists), it displays a "Disconnect Wallet" button that triggers the signOut function from the AuthProvider. Otherwise, it renders the WalletModal for wallet connection. The component uses shadcn/ui's Button for consistent styling and leverages the useAuth hook to access authentication state and methods. The layout is responsive, with Tailwind CSS classes ensuring proper spacing and alignment across devices.

Wallet Modal Component

// components/WalletModal.tsx
import { useMemo } from 'react';
import { useWallet, Wallet, WalletReadyState } from '@solana/wallet-adapter-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import Image from 'next/image';

export default function WalletModal() {
    const { wallets, select } = useWallet();
    
    const installedWallets = useMemo(() => {
        const installed: Wallet[] = [];

        for (const wallet of wallets) {
            if (wallet.readyState === WalletReadyState.Installed) {
                installed.push(wallet);
            }
        }

        return installed;
    }, [wallets]);

    return (
        <Dialog>
            <DialogTrigger asChild>
                <Button className="w-full">
                    Connect Wallet
                </Button>
            </DialogTrigger>
            <DialogContent className="sm:max-w-[450px]">
                <DialogHeader>
                    <DialogTitle>Connect a Wallet</DialogTitle>
                    <DialogDescription>Select a wallet to connect and start using our dApp.</DialogDescription>
                </DialogHeader>
                <div className="grid gap-4 py-2">
                    {installedWallets.length ? (
                        installedWallets.map(wallet => (
                            <Button 
                                key={wallet.adapter.name} 
                                className="rounded-lg border p-4 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-800 cursor-pointer flex items-center justify-start"
                                onClick={() => select(wallet.adapter.name)}
                            >
                                <Image 
                                    src={wallet.adapter.icon} 
                                    width={32} 
                                    height={32} 
                                    alt={${wallet.adapter.name} Wallet} 
                                />
                                <h1 className="ml-3 font-medium">{wallet.adapter.name}</h1>
                            </Button>
                        ))
                    ) : (
                        <h1>You'll need a wallet on Solana to continue</h1>
                    )}
                </div>
            </DialogContent>
        </Dialog>
    );
}

The WalletModal component provides an intuitive interface for selecting and connecting a Solana wallet. It uses the @solana/wallet-adapter-react library to detect installed wallets (WalletReadyState.Installed) and filters them using useMemo for performance optimization. The modal, built with shadcn/ui's Dialog components, displays a list of available wallets with their icons and names, rendered as clickable buttons. Selecting a wallet triggers the select function to initiate the connection process. If no wallets are installed, a fallback message prompts the user to install a Solana wallet. The component is accessible, with proper ARIA attributes, and responsive, using Tailwind CSS for styling.

Supabase Integration Layer

The Supabase adapter abstracts database operations and authentication logic, ensuring type safety and maintainability.

Supabase Adapter Implementation

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
import { Database } from '@/types/database.types';
import { Adapter } from '@/types/adapter';
import { jwtVerify, SignJWT } from 'jose';
import { config } from '@/config';

export const SupabaseAdapter = (supabase: SupabaseClient): Adapter => {
  return {
    getNonce: async (address: string) => {
      const { data, error } = await supabase
          .from('login_attempts')
          .select('nonce')
          .eq('address', address)
          .single();

      if (error) console.error(error);

      return data?.nonce;
    },

    getTLL: async (address: string) => {
      const { data, error } = await supabase
          .from('login_attempts')
          .select('ttl')
          .eq('address', address)
          .single();

      if (error) console.error(error);

      return data?.ttl;
    },

    saveAttempt: async (attempt) => {
      const { error } = await supabase
        .from('login_attempts')
        .upsert(attempt)
        .eq('address', attempt.address)
        .single();

      if (error) console.error(error);
    },
  
    generateToken: async (userId: string) => {
      const payload = {
          sub: userId,
          iat: Math.floor(Date.now() / 1000),
          exp: Math.floor(Date.now() / 1000) + 60 * 60,
      };

      const token = await new SignJWT(payload)
        .setProtectedHeader({ alg: 'HS256' })
        .sign(new TextEncoder().encode(config.SUPABASE_JWT_SECRET));
      await supabase.rpc('set_claim', { uid: userId, claim: 'userrole', value: 'USER' });

      return token;
    },

    isAuthenticated: async (token: string) => {
      try {
        const { payload } = await jwtVerify(token, new TextEncoder().encode(config.SUPABASE_JWT_SECRET));
        const { sub, exp } = payload;
        
        if (!sub) {
          console.error('Invalid token: missing UUID');
          return false;
        }
    
        const currentTime = Math.floor(Date.now() / 1000);
        if (exp && currentTime > exp) {
          console.error('Token has expired');
          return false;
        }
    
        const { data, error } = await supabase.rpc('get_claims', { uid: sub });
        if (error || !data) {
          console.error('User is not authenticated: invalid success claim');
          return false;
        }
    
        return true;
      } catch (error) {
        console.error('Token validation failed:', error);
        return false;
      }
    },
    
    setClaim: async (uid: string, claim: string, value: string) => {
      const { data, error } = await supabase.rpc('set_claim', { uid, claim, value });
      
      if (error) {
        console.error(error);
        return null;
      }
      return data;
    },
  }
};

export const supabase = createClient<Database>(config.SUPABASE_PROJECT_URL, config.SUPABASE_SERVICE_ROLE_KEY);
export const supabaseAuthAdapter = SupabaseAdapter(supabase);

The SupabaseAdapter provides a clean interface for interacting with Supabase, encapsulating database operations and authentication logic. It uses the @supabase/supabase-js client with TypeScript types generated from the database schema (database.types.ts). The adapter includes methods for managing nonces (getNonce, saveAttempt), generating and verifying JWT tokens (generateToken, isAuthenticated), and handling custom claims (setClaim). The generateToken method creates a JWT with a one-hour expiration, signed with the Supabase JWT secret, and sets a userrole claim for role-based access control. The isAuthenticated method verifies token validity and checks user claims, ensuring secure authentication. Error handling is consistent, with detailed logging for debugging. The adapter's design promotes reusability and maintainability, with TypeScript ensuring type safety across all operations.

API Endpoints

The API layer provides secure endpoints for authentication operations, with robust validation and error handling.

Login Endpoint

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAuthAdapter } from '@/lib/supabase';
import { SignMessage } from '@/lib/signMessage';
import { supabase } from '@/lib/supabase';

export async function POST(req: NextRequest) {
    try {
        const { message, signature } = await req.json();

        if (!message || !signature) {
            return NextResponse.json({ error: 'Message and signature are required' }, { status: 400 });
        }

        const signMessage = new SignMessage(JSON.parse(message));

        const validationResult = await signMessage.validate(signature);
        if (!validationResult) {
            return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
        }

        const storedNonce = await supabaseAuthAdapter.getNonce(signMessage.publicKey);
        if (storedNonce !== signMessage.nonce) {
            return NextResponse.json({ error: 'Invalid nonce' }, { status: 401 });
        }

        const address = signMessage.publicKey;
        let user = await supabase
            .from('users')
            .select('*')
            .eq('address', address)
            .single();

        if (user.error && user.error.code !== 'PGRST116') {
            throw user.error;
        } else if (!user.data) {
            const { data: authUser, error: authError } = await supabase.auth.admin.createUser({
                email: ${address}@email.com,
                user_metadata: { address },
            });
            if (authError) throw authError;

            const newUser = await supabase
                .from('users')
                .insert({ address, id: authUser.user.id })
                .select()
                .single();

            if (newUser.error) throw newUser.error;

            user = newUser;
        }

        const token = await supabaseAuthAdapter.generateToken(user.data.id);
        await supabase
            .from('users')
            .update({
                nonce: null,
                last_auth: new Date().toISOString(),
                last_auth_status: 'success',
            })
            .eq('address', address);

        return NextResponse.json({ token, user: user.data }, { status: 200 });
    } catch (error: any) {
        console.error('Error during login:', error);
        return NextResponse.json({ error: error.message || 'Login failed' }, { status: error.status || 500 });
    }
}

The /api/auth/login endpoint handles user authentication by verifying a signed message from the client. It expects a JSON payload containing the message (including public key, nonce, and statement) and signature. The SignMessage class validates the signature using Solana's Ed25519 cryptography, ensuring the message was signed by the wallet owner. The endpoint checks the nonce against the stored value in Supabase to prevent replay attacks. If the user doesn't exist, it creates a new user in Supabase's auth.users table and links it to the users table. A JWT token is generated for the user, and the users table is updated to clear the nonce and log the authentication attempt. Error handling is comprehensive, with specific status codes and messages for invalid inputs, signatures, or nonces, ensuring robust security and clear feedback.

Logout Endpoint

// app/api/auth/logout/route.ts
import { supabase } from '@/lib/supabase';
import { NextResponse } from 'next/server';

export async function POST() {
    const { error } = await supabase.auth.signOut();

    if (error) {
        console.error('Logout error:', error);
        return NextResponse.json({ error: error.message }, { status: 500 });
    }

    const response = NextResponse.json({ message: 'Logged out' });
    response.cookies.set('token', '', { maxAge: 0 });
    return response;
}

The /api/auth/logout endpoint handles user logout by calling Supabase's signOut method to invalidate the session. It removes the authentication token from cookies by setting its maxAge to 0, ensuring the client is fully logged out. Error handling logs any issues and returns a 500 status code with a descriptive message. The endpoint is simple but effective, ensuring secure session termination and proper state cleanup.

Nonce Endpoint

// app/api/auth/nonce/route.ts
import { supabaseAuthAdapter } from '@/lib/supabase';
import { NextRequest, NextResponse } from 'next/server';
import { v4 as uuid } from 'uuid';

export async function POST(req: NextRequest) {
    try {
        const { address } = await req.json();

        if (!address) {
            return NextResponse.json({ error: 'Address is required' }, { status: 400 });
        }

        const nonce = uuid();
        const attempt = {
            address,
            nonce,
            ttl: (Math.floor(Date.now() / 1000) + 300).toString(), // 5 minutes TTL
        };

        await supabaseAuthAdapter.saveAttempt(attempt);

        return NextResponse.json({ nonce }, { status: 200 });
    } catch (error) {
        console.error('Error generating nonce:', error);
        return NextResponse.json({ error: 'Failed to generate nonce' }, { status: 500 });
    }
}

The /api/auth/nonce endpoint generates a unique nonce for a given wallet address, which is used to secure the authentication process. It expects a JSON payload with the wallet address, generates a UUID-based nonce, and stores it in the login_attempts table with a 5-minute time-to-live (TTL). The nonce is returned to the client for signing. The endpoint validates the input address and handles errors gracefully, returning appropriate status codes and messages. This mechanism prevents replay attacks by ensuring each authentication attempt uses a fresh, time-limited nonce.

Cryptographic Message Handling

The SignMessage class provides a secure foundation for signing and verifying messages, leveraging Solana's cryptographic standards.

// lib/signMessage.ts
import * as nacl from 'tweetnacl';
import bs58 from 'bs58';

type SignMessageProps = {
  publicKey: string;
  nonce: string;
  statement: string;
};

export class SignMessage {
  publicKey: string;
  nonce: string;
  statement: string;

  constructor({ publicKey, nonce, statement }: SignMessageProps) {
    this.publicKey = publicKey;
    this.nonce = nonce;
    this.statement = statement;    
  }

  prepare() {
    return ${this.statement}${this.nonce};
  }

  async validate(signature: string) {
    const msg = this.prepare();
    const signatureUint8 = bs58.decode(signature);
    const msgUint8 = new TextEncoder().encode(msg);
    const pubKeyUint8 = bs58.decode(this.publicKey);

    return nacl.sign.detached.verify(msgUint8, signatureUint8, pubKeyUint8);
  }
}

The SignMessage class encapsulates the logic for preparing and verifying signed messages. The prepare method combines the statement and nonce into a single string for signing, ensuring a consistent message format. The validate method uses the tweetnacl library to verify the signature against the message and public key using Solana's Ed25519 signature scheme. The signature, message, and public key are converted from base58 to Uint8Array for cryptographic operations. This class ensures secure verification of wallet ownership, preventing unauthorized access, and is designed to be reusable across different authentication flows.

Route Protection Middleware

The middleware secures API routes by validating JWT tokens, ensuring only authenticated users access protected resources.

// middleware.ts
import { supabaseAuthAdapter } from '@/lib/supabase';
import { NextRequest, NextResponse } from 'next/server';

export default async function middleware(request: NextRequest) {
    const token = request.cookies.get('token')?.value;
    if (!token) {
        return new NextResponse(JSON.stringify({ error: 'Unauthorized' }), {
            status: 401,
            headers: { 'Content-Type': 'application/json' },
        });
    }

    try {
        const auth = await supabaseAuthAdapter.isAuthenticated(token);
        if (!auth) {
            return new NextResponse(JSON.stringify({ error: 'Invalid or expired token' }), {
                status: 401,
                headers: { 'Content-Type': 'application/json' },
            });
        }

        return NextResponse.next();
    } catch (error: any) {
        console.error('Unexpected error during authentication:', error);
        return new NextResponse(JSON.stringify({ error: 'Internal Server Error' }), {
            status: 500,
            headers: { 'Content-Type': 'application/json' },
        });
    }
}

export const config = {
    matcher: [
        '/api/product/:path*',
        '/api/solana/:path*',
        '/api/user/:path*',
    ],
};

The middleware protects specified API routes by checking for a valid JWT token in the request cookies. If no token is present, it returns a 401 Unauthorized response. The supabaseAuthAdapter.isAuthenticated method verifies the token's validity and user claims, ensuring the user is authenticated and the token is not expired. If authentication fails, a 401 response is returned; otherwise, the request proceeds. The middleware is applied to specific routes via the matcher configuration, covering product, Solana, and user-related APIs. Error handling is robust, logging unexpected errors and returning a 500 status code, ensuring secure and reliable route protection.

Database Schema Design

The database schema is optimized for performance and scalability, with proper indexing and relationships.

Users Table Schema

CREATE TABLE users (
  id UUID REFERENCES auth.users(id) PRIMARY KEY,
  address TEXT UNIQUE NOT NULL,
  avatar_url TEXT,
  billing_address JSONB,
  email TEXT,
  full_name TEXT,
  last_auth TIMESTAMP WITH TIME ZONE,
  last_auth_status TEXT,
  nonce TEXT,
  payment_method JSONB,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

The users table stores user data, with a primary key (id) referencing Supabase's auth.users table for authentication integration. The address field uniquely identifies users by their Solana wallet address. Flexible fields like billing_address and payment_method use JSONB for extensibility, while avatar_url, email, and full_name support user profiles. The last_auth and last_auth_status fields track authentication attempts, and nonce stores temporary nonces for authentication. Timestamps (created_at, updated_at) enable audit trails. The schema is designed for scalability, with a unique constraint on address and efficient querying via the primary key.

Login Attempts Table Schema

CREATE TABLE login_attempts (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  address TEXT NOT NULL,
  nonce TEXT NOT NULL,
  ttl TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

The login_attempts table logs authentication attempts, storing the wallet address, nonce, and TTL (time-to-live) for each attempt. The id is a UUID for unique identification, and created_at tracks when the attempt was made. The table supports nonce-based security by storing temporary nonces with a 5-minute TTL, preventing replay attacks. The schema is lightweight and optimized for frequent writes and reads, with no foreign key constraints to maximize performance.

Production Considerations

To prepare for production, consider the following enhancements:

Security Enhancements

  1. Rate Limiting: Use a library like express-rate-limit to limit authentication endpoint requests
  1. Input Validation: Implement a validation library (e.g., Zod) to sanitize inputs
  1. Security Headers: Add headers like CSP and X-Frame-Options using Next.js middleware
  1. CSRF Protection: Include CSRF tokens for POST requests to prevent cross-site request forgery

Performance Optimizations

  1. Database Indexing: Add indexes on users.address and login_attempts.address for faster queries
  1. Caching: Use Redis to cache user data and nonces, reducing database load
  1. Connection Pooling: Configure Supabase client connection pooling for high traffic
  1. CDN: Serve static assets via a CDN like Cloudflare for faster delivery

Monitoring and Logging

  1. Error Tracking: Use Sentry for real-time error monitoring
  1. Performance Monitoring: Implement tools like New Relic for performance insights
  1. User Analytics: Track authentication metrics using PostHog or similar
  1. Health Checks: Add /health endpoints to monitor application status

Additional Resources

For further reading on Supabase and Web3 authentication:

This authentication system provides a secure, scalable foundation for Solana dApps, leveraging Supabase for robust user management and Next.js for a modern frontend experience. With comprehensive error handling, type safety, and modular design, it serves as a solid starting point for production-grade applications.