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/drizzleBackend 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:3001Best 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.
Related Articles
JWT Authentication in Express.js and Node.js: Complete Guide
Learn how to implement JWT authentication with bcrypt password hashing and protected routes.
Express.js REST API Setup: Complete Guide with Error Handling
Learn how to set up a production-ready Express.js REST API with CORS and error handling.
Stripe Subscription Payment Implementation: Complete Guide
Learn how to implement Stripe subscription payments with checkout sessions and webhooks.
React Router Setup: Complete Guide for React Applications
Learn how to set up React Router DOM with routes, navigation, and protected routes.