Authentication
Complete guide to the authentication system powered by Better Auth
Authentication
The starter kit uses Better Auth for a secure, flexible authentication system with email/password and OAuth support.
Overview
Features
- ✅ Email & Password authentication
- ✅ Magic Link passwordless authentication
- ✅ OAuth providers (Google, GitHub)
- ✅ Email verification required for new accounts
- ✅ Password reset flow
- ✅ Session management with cookies
- ✅ Role-based access control (RBAC)
- ✅ Rate limiting on auth endpoints
- ✅ Security audit logging
Authentication Flows
graph TD
A[User] -->|Sign Up| B[Create Account]
B --> C[Send Verification Email]
C --> D[Verify Email]
D --> E[Account Active]
A -->|Sign In| F[Check Credentials]
F -->|Valid| G[Create Session]
F -->|Invalid| H[Show Error]
A -->|OAuth| I[Redirect to Provider]
I --> J[Provider Authentication]
J --> K[Callback with Token]
K --> G
A -->|Magic Link| N[Send Magic Link Email]
N --> O[Click Link in Email]
O --> G
G --> L[Set Session Cookie]
L --> M[Access Protected Routes]Configuration
Authentication is configured in src/lib/auth.ts:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg", // PostgreSQL
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
magicLink: {
disableSignUp: true, // Only allow existing users to use magic link
sendMagicLink: async ({ email, token, url }, request) => {
// Custom email sending logic
await emailService.sendMagicLinkEmail({
email,
magicLinkUrl: url,
expiresIn: "15 minutes",
});
},
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update every 24 hours
},
// ... more configuration
});Database Schema
Better Auth uses these tables (auto-created):
Users Table
{
id: string;
email: string;
emailVerified: boolean;
name: string | null;
image: string | null;
createdAt: Date;
updatedAt: Date;
role: "user" | "admin"; // Custom field
}Sessions Table
{
id: string;
userId: string;
expiresAt: Date;
token: string;
ipAddress: string | null;
userAgent: string | null;
}Accounts Table
For OAuth providers:
{
id: string;
userId: string;
provider: string; // "google", "github"
providerAccountId: string;
refreshToken: string | null;
accessToken: string | null;
expiresAt: Date | null;
}Verification Tokens
For email verification and password reset:
{
id: string;
identifier: string; // Email address
token: string;
expiresAt: Date;
type: "email-verification" | "password-reset" | "magic-link";
}Magic Link Authentication
The starter kit supports passwordless "Magic Link" authentication. This is configured to work for existing users only (disableSignUp: true) to ensure security and prevent account creation without a full sign-up flow.
Sign In Flow
- User enters email at
/sign-inunder the Magic Link tab. - Application verifies the user exists in the database.
- A secure, one-time-use link is sent to the user's email.
- User clicks the link and is automatically signed in.
Magic Link Component
// app/(auth)/sign-in/page.tsx
"use client";
import { authClient } from "@/lib/auth-client";
export function MagicLinkSignIn() {
const handleMagicLink = async (email: string) => {
const result = await authClient.signIn.magicLink({
email,
callbackURL: "/dashboard",
});
if (result.error) {
toast.error("No account found with this email. Please sign up first.");
return;
}
toast.success("Magic link sent! Check your email.");
};
// ... render form
}Security Details
- Expiration: Magic links expire after 15 minutes.
- Single Use: Links are invalidated immediately after a successful sign-in.
- Audit Logging: Every magic link request and usage is logged in the security audit system.
Email & Password Authentication
Sign Up Flow
- User fills sign-up form at
/sign-up - Application creates user account
- Sends verification email
- User clicks verification link
- Account marked as verified
- User can sign in
Sign Up Component
// app/(auth)/sign-up/page.tsx
"use client";
import { authClient } from "@/lib/auth-client";
export default function SignUpPage() {
const handleSignUp = async (data: SignUpData) => {
const result = await authClient.signUp.email({
email: data.email,
password: data.password,
name: data.name,
});
if (result.error) {
// Handle error
toast.error(result.error.message);
return;
}
// Success - verification email sent
toast.success("Check your email to verify your account");
router.push("/verify-email");
};
return <SignUpForm onSubmit={handleSignUp} />;
}Sign In Flow
- User enters email and password at
/sign-in - Application verifies credentials
- Creates session on success
- Redirects to dashboard
Sign In Component
// app/(auth)/sign-in/page.tsx
"use client";
import { authClient } from "@/lib/auth-client";
export default function SignInPage() {
const handleSignIn = async (data: SignInData) {
const result = await authClient.signIn.email({
email: data.email,
password: data.password,
});
if (result.error) {
toast.error(result.error.message);
return;
}
// Success - redirect to dashboard
router.push("/dashboard");
};
return <SignInForm onSubmit={handleSignIn} />;
}Email Verification
After sign-up, users receive a verification email:
// Verification email is sent automatically by Better Auth
// Template can be customized in email provider settings
// Verification link format:
// http://localhost:3000/api/auth/verify-email?token=xxxThe verification page at /verify-email handles the token and verifies the email.
Password Reset Flow
- User requests password reset at
/forgot-password - Sends reset email with token
- User clicks link and goes to
/reset-password?token=xxx - User enters new password
- Password updated, user can sign in
Forgot Password
// app/(auth)/forgot-password/page.tsx
"use client";
import { authClient } from "@/lib/auth-client";
export default function ForgotPasswordPage() {
const handleResetRequest = async (email: string) => {
const result = await authClient.forgetPassword({
email,
redirectTo: "/reset-password",
});
if (result.error) {
toast.error("Failed to send reset email");
return;
}
toast.success("Check your email for reset instructions");
};
return <ForgotPasswordForm onSubmit={handleResetRequest} />;
}Reset Password
// app/(auth)/reset-password/page.tsx
"use client";
export default function ResetPasswordPage() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const handleReset = async (newPassword: string) {
const result = await authClient.resetPassword({
token: token!,
newPassword,
});
if (result.error) {
toast.error("Failed to reset password");
return;
}
toast.success("Password reset successful!");
router.push("/sign-in");
};
return <ResetPasswordForm onSubmit={handleReset} />;
}OAuth Authentication
Google OAuth
- Configure Google OAuth in
.env:
GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-client-secret"- Use the OAuth button:
// components/auth/oauth-buttons.tsx
"use client";
import { authClient } from "@/lib/auth-client";
export function GoogleSignInButton() {
const handleGoogleSignIn = async () => {
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
};
return (
<Button onClick={handleGoogleSignIn}>
<GoogleIcon />
Continue with Google
</Button>
);
}GitHub OAuth
Similar to Google:
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"await authClient.signIn.social({
provider: "github",
callbackURL: "/dashboard",
});OAuth Callback
Better Auth handles the OAuth callback automatically at:
/api/auth/callback/google
/api/auth/callback/githubNo additional setup needed!
Session Management
Getting the Current Session
Server Components
// app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
return (
<div>
<h1>Welcome, {session.user.name}!</h1>
<p>Email: {session.user.email}</p>
</div>
);
}Client Components
// components/user-profile.tsx
"use client";
import { useSession } from "@/lib/auth-client";
export function UserProfile() {
const { data: session, isPending } = useSession();
if (isPending) return <Skeleton />;
if (!session) return <SignInPrompt />;
return (
<div>
<Avatar src={session.user.image} />
<h2>{session.user.name}</h2>
<p>{session.user.email}</p>
</div>
);
}Signing Out
// components/sign-out-button.tsx
"use client";
import { authClient } from "@/lib/auth-client";
export function SignOutButton() {
const handleSignOut = async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/");
},
},
});
};
return <Button onClick={handleSignOut}>Sign Out</Button>;
}Protecting Routes
Server-Side Protection
Use requireAuth for server components:
// app/dashboard/admin/page.tsx
import { requireAuth } from "@/lib/requireAuth";
export default async function AdminPage() {
// Only admin users can access this page
const session = await requireAuth("admin");
return <AdminDashboard user={session.user} />;
}The requireAuth function:
- Checks if user is authenticated
- Verifies user has the required role
- Redirects to sign-in if not authenticated
- Shows 403 error if role doesn't match
Client-Side Protection
Use AuthGuard for client components:
// components/admin-panel.tsx
"use client";
import { AuthGuard } from "@/lib/auth-guard";
export function AdminPanel() {
return (
<AuthGuard allowedRoles={["admin"]}>
<AdminContent />
</AuthGuard>
);
}Middleware Protection
Protect API routes with middleware:
// app/api/admin/route.ts
import { auth } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Handle admin request
}Security Features
Rate Limiting
Auth endpoints are rate-limited to prevent brute force attacks:
// src/lib/rate-limit.ts
export const RateLimitPresets = {
AUTH_LOGIN: {
maxRequests: 5,
windowSeconds: 15 * 60, // 15 minutes
},
PASSWORD_RESET: {
maxRequests: 3,
windowSeconds: 60 * 60, // 1 hour
},
EMAIL_VERIFICATION: {
maxRequests: 5,
windowSeconds: 60 * 60, // 1 hour
},
};Rate limits:
- Sign In: 5 attempts per 15 minutes
- Password Reset: 3 requests per hour
- Email Verification: 5 requests per hour
Security Audit Logging
All authentication events are logged:
import { logSecurityEvent } from "@/lib/security-audit";
// On sign in
await logSecurityEvent({
eventType: "user_login",
severity: "info",
userId: user.id,
ipAddress: request.ip,
userAgent: request.headers.get("user-agent"),
metadata: { method: "email" },
});
// On failed sign in
await logSecurityEvent({
eventType: "failed_login_attempt",
severity: "warning",
metadata: { email, reason: "Invalid credentials" },
});Admins can view audit logs at /dashboard/admin/security-logs.
Password Security
- Passwords hashed with scrypt (secure algorithm)
- Minimum 8 characters required
- Email verification required before access
- Password reset tokens expire in 1 hour
Common Use Cases
Check if User is Admin
// Server component
const session = await auth.api.getSession({ headers: await headers() });
const isAdmin = session?.user?.role === "admin";
// Client component
const { data: session } = useSession();
const isAdmin = session?.user?.role === "admin";Redirect After Sign In
// Redirect to different pages based on role
const handleSignIn = async () => {
const result = await authClient.signIn.email({ email, password });
if (result.data?.user) {
const redirectPath =
result.data.user.role === "admin"
? "/dashboard/admin"
: "/dashboard/user";
router.push(redirectPath);
}
};Require Email Verification
Email verification is required by default. To access the app, users must:
- Sign up
- Check email
- Click verification link
- Sign in
Until verified, they can't sign in.
Custom Role Checks
// lib/auth-utils.ts
export function hasRole(session: Session | null, roles: string[]) {
if (!session?.user) return false;
return roles.includes(session.user.role);
}
// Usage
if (hasRole(session, ["admin", "moderator"])) {
// User is admin or moderator
}Customization
Adding New OAuth Providers
Better Auth supports many providers. To add a new one:
- Install the provider package (if needed)
- Add credentials to
.env - Configure in
lib/auth.ts:
export const auth = betterAuth({
// ... existing config
socialProviders: {
google: {
/* ... */
},
github: {
/* ... */
},
// Add new provider
twitter: {
clientId: process.env.TWITTER_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
},
},
});- Add button to sign-in page
Custom Email Templates
Email templates can be customized in the email provider configuration. See Email Configuration for details.
Adding Custom Fields to User
- Update schema in
src/db/schema/auth.ts - Push schema:
pnpm db:push - Fields will be available in
session.user
Troubleshooting
"Session not found" errors
- Ensure cookies are enabled in browser
- Check
BETTER_AUTH_URLmatches your domain - Verify session hasn't expired
OAuth redirect errors
- Verify redirect URIs match exactly in provider settings
- Check
BETTER_AUTH_URLis correct - Ensure OAuth credentials are valid
Email verification not working
- Check email provider is configured
- Verify emails aren't going to spam
- Check application logs for errors
Rate limit hit
- Wait for the rate limit window to expire
- Contact admin if legitimate use is being blocked
- Adjust rate limits in
src/lib/rate-limit.tsif needed