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.
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.
π Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.
What is Better Auth?
Better Auth is a modern authentication library for Node.js and React applications. It provides:
- Type-safe authentication - Full TypeScript support with autocomplete
- Email/password authentication - Built-in email and password auth
- Session management - Automatic session handling with cookies
- Multiple database adapters - Drizzle ORM, Prisma, and more
- Custom password hashing - Use scrypt, bcrypt, or your own hashing
- OAuth support - Integrate with social providers
- Protected routes - Easy route protection for both client and server
- Production-ready - Designed for SaaS applications
Installation
First, let's install Better Auth and a database adapter:
npm install better-auth
npm install better-auth/adapters/drizzle
For Drizzle ORM (used in this example):
npm install drizzle-orm drizzle-kit
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!],
});
Key Configuration Options
-
secret- A strong, random secret for signing tokens -
baseURL- Your backend URL -
basePath- Path where auth routes will be mounted -
database- Database adapter configuration -
emailAndPassword- Enable email/password authentication -
session- Session expiration and update settings -
trustedOrigins- CORS origins that can access auth endpoints
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';
import cookieParser from 'cookie-parser';
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');
});
The toNodeHandler function converts Better Auth's handler to work with Express.js, handling all authentication endpoints automatically.
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;
}
Wrap your app with the provider:
// app/layout.tsx or _app.tsx
import { AuthProvider } from '@/contexts/AuthContext';
export default function RootLayout({ children }) {
return (
<html>
<body>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}
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 (using Drizzle ORM):
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
// 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
Configure your .env file:
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
Important:
- Generate a strong, random secret for
BETTER_AUTH_SECRET - Never commit your
.envfile to version control - Use different secrets for development and production
Better Auth vs NextAuth
| Feature | Better Auth | NextAuth |
|---|---|---|
| Type Safety | Full TypeScript support | Limited |
| Database Adapters | Drizzle, Prisma, custom | Limited options |
| Custom Password Hashing | Fully customizable | Limited |
| Session Management | Automatic with cookies | Automatic |
| React Integration | Built-in hooks | Built-in hooks |
| Express.js Support | Native support | Requires adapter |
| Production Ready | Yes | Yes |
Better Auth provides more flexibility and type safety, making it ideal for modern TypeScript applications.
Best Practices
-
Always use a strong, random secret - Generate secrets using
openssl rand -base64 32 - Store secrets in environment variables - Never hardcode secrets in your code
- Use custom password hashing - Implement scrypt or bcrypt for better security control
- Set appropriate session expiration - Balance security and user experience
- Enable CORS with credentials - For cookie-based sessions to work across origins
- Use ProtectedRoute component - For client-side route protection
- Verify sessions in middleware - For server-side protection
- Handle loading states - During authentication checks
- Implement error handling - Gracefully handle authentication errors
- Use HTTPS in production - Essential for secure cookie transmission
Common Patterns
Server-Side Session Check
// In Next.js API route or server component
import { auth } from '@/lib/auth';
export async function GET(request: Request) {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
return new Response('Unauthorized', { status: 401 });
}
return Response.json({ user: session.user });
}
Client-Side Session Check
'use client';
import { useSession } from '@/lib/auth-client';
function UserProfile() {
const { data: session, isPending } = useSession();
if (isPending) return <div>Loading...</div>;
if (!session?.user) return <div>Not authenticated</div>;
return <div>Welcome, {session.user.email}!</div>;
}
Resources and Further Reading
- π Full Better Auth Guide - Complete tutorial with advanced examples, troubleshooting, and best practices
- JWT Authentication Guide - Alternative authentication approach with JWT
- Better Auth Documentation - Official Better Auth documentation
- Better Auth GitHub - Source code and examples
- Express.js REST API Setup - Learn how to integrate Better Auth with Express.js
- Stripe Subscription Guide - Add payments to your authenticated app
- React Router Setup Guide - Set up protected routes with React Router
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.
Key Takeaways:
- Better Auth simplifies authentication with type-safe APIs
- Custom password hashing gives you full control over security
- Session management is handled automatically with cookies
- Multiple database adapters support various ORMs
- React integration is seamless with built-in hooks
- Production-ready for SaaS applications
- Express.js support makes it easy to integrate with Node.js backends
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.
What's your experience with Better Auth? Share your tips and tricks in the comments below! π
π‘ Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.
If you found this guide helpful, consider checking out my other articles on Node.js development and authentication best practices.
Top comments (0)