Back to Blog
Node.js18 min read

Better Auth Implementation Guide: Complete Authentication Setup

Better Auth is a modern, type-safe authentication library that simplifies user authentication in Node.js and React applications. In this guide, we'll learn how to implement complete authentication with email/password, session management, and protected routes.

Authentication is one of those features that every application needs, but building it from scratch is time-consuming and error-prone. I've tried many authentication solutions over the years, and Better Auth stands out for its simplicity, type safety, and flexibility. It handles sessions, password hashing, and all the edge cases automatically, while giving you full control over the implementation.

In this guide, I'll show you how to set up Better Auth in a production SaaS application. We'll cover backend configuration, React integration, protected routes, and custom password hashing. The examples are minimal and focused on what you actually need to get authentication working.

Installation

npm install better-auth
npm install better-auth/adapters/drizzle

Backend Setup: Auth Configuration

Configure Better Auth with your database adapter and custom password hashing:

import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from './db/index.js';
import * as schema from './db/schema.js';
import { scrypt, randomBytes, timingSafeEqual } from 'crypto';
import { promisify } from 'util';

const scryptAsync = promisify(scrypt);

// Custom password hashing with scrypt
async function hashPassword(password: string): Promise<string> {
  const salt = randomBytes(16).toString('hex');
  const normalizedPassword = password.normalize('NFKC');
  const derivedKey = (await scryptAsync(normalizedPassword, salt, 64, {
    N: 16384,
    r: 8,
    p: 1,
  })) as Buffer;
  return `${salt}:${derivedKey.toString('hex')}`;
}

async function verifyPassword(data: { hash: string; password: string }): Promise<boolean> {
  const [salt, storedKey] = data.hash.split(':');
  if (!salt || !storedKey) return false;

  const normalizedPassword = data.password.normalize('NFKC');
  const derivedKey = (await scryptAsync(normalizedPassword, salt, 64, {
    N: 16384,
    r: 8,
    p: 1,
  })) as Buffer;

  const storedKeyBuffer = Buffer.from(storedKey, 'hex');
  return timingSafeEqual(derivedKey, storedKeyBuffer);
}

export const auth = betterAuth({
  secret: process.env.BETTER_AUTH_SECRET!,
  baseURL: process.env.BETTER_AUTH_URL!,
  basePath: '/api/auth',
  database: drizzleAdapter(db, {
    provider: 'sqlite',
    schema: {
      user: schema.users,
      session: schema.sessions,
      account: schema.accounts,
      verification: schema.verifications,
    },
  }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: false,
    password: {
      hash: hashPassword,
      verify: verifyPassword,
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24, // 1 day
  },
  trustedOrigins: [process.env.FRONTEND_URL!],
});

Express.js Integration

Mount Better Auth routes in your Express server:

import express from 'express';
import { toNodeHandler } from 'better-auth/node';
import { auth } from './lib/auth.js';

const app = express();

app.use(express.json());
app.use(cookieParser());

// Mount Better Auth routes
app.all('/api/auth/*', toNodeHandler(auth));

app.listen(3001, () => {
  console.log('Server running on http://localhost:3001');
});

Authentication Middleware

Create middleware to protect routes:

import { Request, Response, NextFunction } from 'express';
import { auth } from './lib/auth.js';

export interface AuthRequest extends Request {
  user?: {
    id: string;
    email: string;
    name?: string;
    role?: string;
  };
}

export async function requireAuth(
  req: AuthRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const token = req.headers.authorization?.startsWith('Bearer ')
      ? req.headers.authorization.substring(7)
      : req.cookies?.['better-auth.session_token'];

    if (!token) {
      res.status(401).json({ error: 'Unauthorized' });
      return;
    }

    // Verify session
    const session = await auth.api.getSession({
      headers: {
        cookie: `better-auth.session_token=${token}`,
      } as unknown as Headers,
    });

    if (!session || !session.user) {
      res.status(401).json({ error: 'Unauthorized' });
      return;
    }

    req.user = {
      id: session.user.id,
      email: session.user.email,
      name: session.user.name || undefined,
    };

    next();
  } catch (error) {
    res.status(401).json({ error: 'Unauthorized' });
  }
}

// Use in routes
router.get('/protected', requireAuth, (req: AuthRequest, res) => {
  res.json({ message: 'Protected route', user: req.user });
});

React Client Setup

Create the auth client for React:

import { createAuthClient } from 'better-auth/react';

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
  basePath: '/api/auth',
});

export const { signIn, signUp, signOut, useSession } = authClient;

Auth Context Provider

Create a context to share auth state across your app:

'use client';

import { createContext, useContext, ReactNode } from 'react';
import { useSession } from '@/lib/auth-client';

interface AuthContextType {
  user: { id: string; email: string; name?: string } | null;
  isLoading: boolean;
  isAuthenticated: boolean;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const { data: session, isPending } = useSession();

  const value: AuthContextType = {
    user: session?.user || null,
    isLoading: isPending,
    isAuthenticated: !!session?.user,
  };

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

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

Login Page

Implement login with Better Auth:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signIn } from '@/lib/auth-client';

export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      const result = await signIn.email({
        email,
        password,
      });

      if (result.error) {
        setError(result.error.message || 'Login failed');
        setIsLoading(false);
        return;
      }

      router.push('/dashboard');
    } catch (err: any) {
      setError(err.message || 'An error occurred');
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      {error && <div className="error">{error}</div>}
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  );
}

Registration Page

Implement user registration:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signUp } from '@/lib/auth-client';

export default function RegisterPage() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      const result = await signUp.email({
        email,
        password,
        name,
      });

      if (result.error) {
        setError(result.error.message || 'Registration failed');
        setIsLoading(false);
        return;
      }

      router.push('/dashboard');
    } catch (err: any) {
      setError(err.message || 'An error occurred');
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Name"
        required
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      {error && <div className="error">{error}</div>}
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Creating account...' : 'Create account'}
      </button>
    </form>
  );
}

Protected Route Component

Create a component to protect routes:

'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';

export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { isAuthenticated, isLoading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      router.push('/login');
    }
  }, [isAuthenticated, isLoading, router]);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (!isAuthenticated) {
    return null;
  }

  return <>{children}</>;
}

// Usage
export default function DashboardPage() {
  return (
    <ProtectedRoute>
      <div>Protected content</div>
    </ProtectedRoute>
  );
}

Sign Out

Implement sign out functionality:

'use client';

import { signOut } from '@/lib/auth-client';
import { useRouter } from 'next/navigation';

function LogoutButton() {
  const router = useRouter();

  const handleSignOut = async () => {
    await signOut();
    router.push('/login');
  };

  return <button onClick={handleSignOut}>Sign Out</button>;
}

Database Schema

Required database tables for Better Auth:

// Users table
export const users = sqliteTable('users', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  emailVerified: integer('email_verified', { mode: 'boolean' }).default(false),
  image: text('image'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});

// Sessions table
export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
  token: text('token').notNull().unique(),
  userId: text('user_id').notNull().references(() => users.id),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});

// Accounts table (for OAuth providers)
export const accounts = sqliteTable('accounts', {
  id: text('id').primaryKey(),
  accountId: text('account_id').notNull(),
  providerId: text('provider_id').notNull(),
  userId: text('user_id').notNull().references(() => users.id),
  accessToken: text('access_token'),
  refreshToken: text('refresh_token'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});

// Verifications table (for email verification)
export const verifications = sqliteTable('verifications', {
  id: text('id').primaryKey(),
  identifier: text('identifier').notNull(),
  value: text('value').notNull(),
  expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' }),
});

Environment Variables

BETTER_AUTH_SECRET=your-secret-key-here
BETTER_AUTH_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3001

Best Practices

  • Always use a strong, random secret for BETTER_AUTH_SECRET
  • Store secrets in environment variables, never in code
  • Use custom password hashing for better security control
  • Set appropriate session expiration times
  • Enable CORS with credentials for cookie-based sessions
  • Use ProtectedRoute component for client-side route protection
  • Verify sessions in middleware for server-side protection
  • Handle loading states during authentication checks

Conclusion

Better Auth provides a clean, type-safe way to handle authentication in modern web applications. With minimal setup, you get email/password authentication, session management, and all the infrastructure you need. The library handles the complexity while giving you full control over the implementation details.

The key advantages I've found: type safety with TypeScript, flexible database adapters, custom password hashing support, and simple React integration. It's perfect for SaaS applications where you need reliable authentication without the overhead of managing sessions and tokens manually.