Skip to main content

Overview

Kuest Prediction Market uses a hybrid authentication system combining:
  • Better Auth: Modern authentication framework with session management
  • Reown AppKit: Web3 wallet connection interface (formerly WalletConnect)
  • SIWE (Sign-In with Ethereum): Cryptographic wallet authentication
  • 2FA Support: TOTP-based two-factor authentication
Users authenticate by connecting their Web3 wallet and signing a message. No passwords required.

Architecture

The authentication flow:

Better Auth Configuration

Required Environment Variables

.env
BETTER_AUTH_SECRET="your-32-character-random-secret"
Generate a secure secret at Better Auth Secret Generator.
Critical: Changing BETTER_AUTH_SECRET invalidates all user sessions AND encrypted trading credentials. Users must log in and re-authenticate for trading.

Core Configuration

The auth instance is configured in src/lib/auth.ts:
src/lib/auth.ts
export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'pg',
    schema,
  }),
  appName: DEFAULT_THEME_SITE_NAME,
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: SITE_URL,
  advanced: {
    database: {
      generateId: false, // Use custom ULID generation
    },
  },
})

Enabled Plugins

Extends the session with additional user information:
customSession(async ({ user, session }) => {
  return {
    user: {
      ...user,
      image: getPublicAssetUrl(user.image),
      is_admin: isAdminWallet(user.name),
      settings: sanitizedTradingAuthSettings,
    },
    session,
  }
})
Provides:
  • Full-resolution image URLs
  • Admin status based on ADMIN_WALLETS
  • Sanitized trading settings (credentials removed)
Enables wallet-based authentication:
siwe({
  domain: SIWE_DOMAIN,
  emailDomainName: SIWE_EMAIL_DOMAIN,
  anonymous: true,
  getNonce: () => generateRandomString(32),
  verifyMessage: async ({ message, signature, address }) => {
    // Verify signature via WalletConnect RPC
  },
})
Features:
  • Anonymous mode (no email required initially)
  • Multiple wallet support per user
  • Primary wallet designation
  • Automatic email generation: {address}@{domain}
Optional two-factor authentication:
twoFactor({
  skipVerificationOnEnable: false,
  schema: {
    user: {
      fields: { twoFactorEnabled: 'two_factor_enabled' },
    },
    twoFactor: {
      modelName: 'two_factors',
      fields: {
        secret: 'secret',
        backupCodes: 'backup_codes',
        userId: 'user_id',
      },
    },
  },
})
Features:
  • TOTP codes (Google Authenticator, Authy)
  • Backup codes for recovery
  • Trusted device cookies (30 days)
  • Redirect flow for SIWE + 2FA
Cookie handling for Next.js App Router:
nextCookies()
Provides:
  • Server component session access
  • Cookie-based session caching (5 minutes)
  • Automatic cookie rotation

Reown AppKit Configuration

Required Environment Variables

.env
REOWN_APPKIT_PROJECT_ID="your-project-id"
Get your project ID from Reown Dashboard.

Create a Reown Project

  1. Go to https://dashboard.reown.com/
  2. Click New Project
  3. Configure:
    • Name: Your prediction market name
    • Type: Web3Modal v4
    • Networks: Add Polygon (or your target networks)
  4. Copy the Project ID

Configuration

The AppKit is configured in src/lib/appkit.ts:
src/lib/appkit.ts
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'
import { polygonAmoy } from '@reown/appkit/networks'

export const projectId = process.env.REOWN_APPKIT_PROJECT_ID ?? ''

export const defaultNetwork = polygonAmoy
export const networks = [defaultNetwork] as [AppKitNetwork, ...AppKitNetwork[]]

export const wagmiAdapter = new WagmiAdapter({
  ssr: false,
  projectId,
  networks,
})

Supported Networks

By default, the app uses Polygon Amoy (testnet). To change networks:
src/lib/appkit.ts
import { polygon, arbitrum, base } from '@reown/appkit/networks'

// Production: Polygon mainnet
export const defaultNetwork = polygon
export const networks = [polygon, arbitrum, base]
Kuest CLOB currently operates on Polygon. Ensure your defaultNetwork matches your Kuest credentials.

Supported Wallets

Reown AppKit automatically includes:
  • MetaMask: Most popular Web3 wallet
  • WalletConnect: 300+ wallets via WalletConnect protocol
  • Coinbase Wallet: Coinbase’s self-custody wallet
  • Trust Wallet: Mobile-first wallet
  • Rainbow: Ethereum-focused wallet
  • And many more…

Admin Configuration

Setting Admin Wallets

Add admin wallet addresses to your .env file:
.env
ADMIN_WALLETS="0x123...,0x456...,0x789..."
Format options:
# Comma-separated (recommended)
ADMIN_WALLETS="0x123...,0x456..."

# JSON array
ADMIN_WALLETS='["0x123...","0x456..."]'
Addresses are case-insensitive and automatically lowercased for comparison.

Admin Features

Admin users have access to:
  • Event Management: Create/edit market events
  • User Management: View user stats and activity
  • System Settings: Configure platform settings
  • Analytics Dashboard: Advanced metrics and reports

Checking Admin Status

The application provides helper functions:
src/lib/admin.ts
import { getAdminWallets, isAdminWallet } from '@/lib/admin'

// Get all admin wallet addresses
const admins = getAdminWallets()
// → ['0x123...', '0x456...']

// Check if address is admin
const isAdmin = isAdminWallet('0x123...')
// → true

// Check current user
const session = await auth.api.getSession({ headers })
if (session?.user?.is_admin) {
  // User is admin
}

Database Schema

Better Auth creates these tables:

users Table

CREATE TABLE users (
  id                     CHAR(26) PRIMARY KEY,
  address                TEXT NOT NULL UNIQUE,          -- Primary wallet address
  username               TEXT,                          -- Optional display name
  email                  TEXT NOT NULL,                 -- Auto-generated or custom
  email_verified         BOOLEAN DEFAULT FALSE,         -- Email verification status
  two_factor_enabled     BOOLEAN DEFAULT FALSE,         -- 2FA status
  image                  TEXT,                          -- Profile image path
  settings               JSONB,                         -- User preferences
  affiliate_code         TEXT,                          -- User's referral code
  referred_by_user_id    CHAR(26),                      -- Referrer user ID
  created_at             TIMESTAMPTZ DEFAULT NOW(),
  updated_at             TIMESTAMPTZ DEFAULT NOW()
);

wallets Table

CREATE TABLE wallets (
  id          CHAR(26) PRIMARY KEY,
  user_id     CHAR(26) NOT NULL REFERENCES users(id),
  address     TEXT NOT NULL,                    -- Wallet address
  chain_id    INTEGER NOT NULL,                 -- Network chain ID
  is_primary  BOOLEAN DEFAULT FALSE,            -- Primary wallet flag
  created_at  TIMESTAMPTZ NOT NULL
);
Features:
  • Users can connect multiple wallets
  • One wallet is marked as primary
  • Wallets are network-specific (chain_id)

sessions Table

CREATE TABLE sessions (
  id         CHAR(26) PRIMARY KEY,
  user_id    CHAR(26) NOT NULL REFERENCES users(id),
  token      TEXT NOT NULL UNIQUE,              -- Session token
  expires_at TIMESTAMPTZ NOT NULL,              -- Expiration time
  ip_address TEXT,                              -- Client IP
  user_agent TEXT,                              -- Browser info
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
Session duration: 30 days (configurable)

two_factors Table

CREATE TABLE two_factors (
  id           CHAR(26) PRIMARY KEY,
  user_id      CHAR(26) NOT NULL REFERENCES users(id),
  secret       TEXT NOT NULL,                   -- TOTP secret
  backup_codes TEXT NOT NULL,                   -- JSON array of codes
);

Authentication Flows

Standard Sign-In Flow

// 1. User clicks "Connect Wallet"
<w3m-button />

// 2. AppKit shows wallet options
// User selects wallet and approves connection

// 3. Backend generates SIWE message
const message = generateSiweMessage({
  domain: SIWE_DOMAIN,
  address: walletAddress,
  statement: 'Sign in to Kuest Prediction Market',
  nonce: randomNonce,
})

// 4. User signs message in wallet
const signature = await walletClient.signMessage({ message })

// 5. Backend verifies signature
const isValid = await publicClient.verifyMessage({
  message,
  signature,
  address,
})

// 6. Create session
if (isValid) {
  const session = await auth.api.createSession({ userId })
}

Sign-In with 2FA

// 1-5. Same as standard flow

// 6. Check if 2FA is enabled
if (user.two_factor_enabled) {
  // Delete the session
  await deleteSession(sessionToken)
  
  // Store temporary verification token
  await createVerificationValue({
    identifier: '2fa-' + randomId,
    value: userId,
    expiresAt: Date.now() + 3 * 60 * 1000, // 3 minutes
  })
  
  // Redirect to 2FA page
  return { twoFactorRedirect: true }
}

// 7. User enters TOTP code
const isValidTotp = verifyTotp(code, secret)

// 8. Create session after successful 2FA
if (isValidTotp) {
  await createSession({ userId })
  
  // Optionally set trusted device cookie
  if (trustDevice) {
    setTrustedDeviceCookie(userId, sessionToken)
  }
}

Trusted Device Flow

When a user enables “Trust this device”:
// Set cookie with HMAC signature
const token = await createHMAC().sign(
  secret,
  `${userId}!${sessionToken}`
)

setCookie('trust_device', `${token}!${sessionToken}`, {
  maxAge: 720 * 60 * 60, // 30 days
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
})

// On next login, verify cookie
const [token, sessionToken] = cookie.split('!')
const expected = await createHMAC().sign(secret, `${userId}!${sessionToken}`)

if (token === expected) {
  // Skip 2FA for this device
}

Affiliate System Integration

Authentication includes automatic affiliate tracking:
// When user clicks referral link
// Cookie is set: platform_affiliate={affiliateCode: "ABC123", timestamp: Date.now()}

// On user creation
databaseHooks: {
  user: {
    create: {
      async after(user, ctx) {
        const referral = parseAffiliateCookie(ctx.getCookie('platform_affiliate'))
        
        if (referral?.affiliateCode) {
          // Look up affiliate
          const affiliate = await getAffiliateByCode(referral.affiliateCode)
          
          // Record referral
          await recordReferral({
            user_id: user.id,
            affiliate_user_id: affiliate.id,
          })
          
          // Clear cookie
          ctx.setCookie('platform_affiliate', '', { maxAge: 0 })
        }
      },
    },
  },
}
Features:
  • 30-day cookie expiration
  • Automatic referral attribution
  • Self-referral prevention
  • Cookie cleared after registration

Session Management

Server Components

import { auth } from '@/lib/auth'
import { headers } from 'next/headers'

export default async function Page() {
  const session = await auth.api.getSession({
    headers: await headers(),
  })
  
  if (!session) {
    return <div>Please sign in</div>
  }
  
  return <div>Welcome {session.user.address}</div>
}

Client Components

'use client'

import { useSession } from '@/hooks/use-session'

export function ProfileButton() {
  const { data: session, isPending } = useSession()
  
  if (isPending) {
    return <Skeleton />
  }
  
  if (!session) {
    return <w3m-button />
  }
  
  return <UserMenu user={session.user} />
}

API Routes

app/api/profile/route.ts
import { auth } from '@/lib/auth'
import { NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  const session = await auth.api.getSession({
    headers: request.headers,
  })
  
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
  
  return Response.json({
    user: session.user,
  })
}

Troubleshooting

Check:
  1. Correct network: User must be on the network specified in networks array
  2. REOWN_APPKIT_PROJECT_ID: Verify it’s correct and active
  3. SIWE domain: Must match your deployment domain
  4. Time sync: User’s device time must be accurate
Debug:
console.log('SIWE domain:', SIWE_DOMAIN)
console.log('User network:', chainId)
console.log('Expected network:', defaultNetwork.id)
Check BETTER_AUTH_SECRET:
  1. Ensure it’s set and is exactly 32 characters
  2. Verify it hasn’t changed between deployments
  3. Check for environment variable typos
Test:
echo $BETTER_AUTH_SECRET | wc -c
# Should output: 33 (32 chars + newline)
Common issues:
  1. Time drift: Server and user device clocks out of sync
  2. Code expired: TOTP codes are valid for 30 seconds
  3. Wrong secret: User scanned incorrect QR code
Solutions:
  • Sync server time: ntpdate -u time.nist.gov
  • Use backup codes for recovery
  • Disable 2FA via database if necessary
Verify admin configuration:
  1. Check ADMIN_WALLETS is set correctly
  2. Ensure wallet address matches exactly (case-insensitive)
  3. User must sign in AFTER being added to ADMIN_WALLETS
Test:
import { isAdminWallet } from '@/lib/admin'
console.log(isAdminWallet('0x123...')) // Should return true
Check:
  1. Project ID: Valid and not revoked
  2. Network connectivity: User can reach relay.walletconnect.com
  3. Browser console: Look for CORS or loading errors
  4. Project settings: Domain allowlist in Reown dashboard
Debug:
console.log('AppKit Project ID:', projectId)
console.log('Networks:', networks)

Security Best Practices

  1. Rotate secrets: Change BETTER_AUTH_SECRET periodically (requires user re-login)
  2. HTTPS only: Never use auth over HTTP in production
  3. Rate limiting: Limit sign-in attempts and 2FA verification
  4. Session timeout: Keep default 30-day expiration or reduce for sensitive apps
  5. Admin access: Regularly audit ADMIN_WALLETS list
  6. Cookie security: Use httpOnly, secure, and sameSite flags
  7. CSRF protection: Better Auth includes built-in CSRF tokens

Next Steps

Environment Variables

Complete variable reference

Database Setup

PostgreSQL configuration

Storage Configuration

File storage setup

Deploy to Vercel

Deploy your application

Build docs developers (and LLMs) love