Next Starter Kit
Features

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";
}

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

  1. User enters email at /sign-in under the Magic Link tab.
  2. Application verifies the user exists in the database.
  3. A secure, one-time-use link is sent to the user's email.
  4. User clicks the link and is automatically signed in.
// 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

  1. User fills sign-up form at /sign-up
  2. Application creates user account
  3. Sends verification email
  4. User clicks verification link
  5. Account marked as verified
  6. 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

  1. User enters email and password at /sign-in
  2. Application verifies credentials
  3. Creates session on success
  4. 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=xxx

The verification page at /verify-email handles the token and verifies the email.

Password Reset Flow

  1. User requests password reset at /forgot-password
  2. Sends reset email with token
  3. User clicks link and goes to /reset-password?token=xxx
  4. User enters new password
  5. 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

  1. Configure Google OAuth in .env:
GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-client-secret"
  1. 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/github

No 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:

  1. Sign up
  2. Check email
  3. Click verification link
  4. 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:

  1. Install the provider package (if needed)
  2. Add credentials to .env
  3. 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!,
    },
  },
});
  1. 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

  1. Update schema in src/db/schema/auth.ts
  2. Push schema: pnpm db:push
  3. Fields will be available in session.user

Troubleshooting

"Session not found" errors

  • Ensure cookies are enabled in browser
  • Check BETTER_AUTH_URL matches your domain
  • Verify session hasn't expired

OAuth redirect errors

  • Verify redirect URIs match exactly in provider settings
  • Check BETTER_AUTH_URL is 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.ts if needed

Next Steps

On this page