GameCraftGameCraft

Authentication

User authentication with Better Auth - sign up, login, OAuth, and session management

Authentication

ProductReady uses Better Auth for modern, secure authentication. This guide covers everything from basic email/password to OAuth integrations.

Why Better Auth? Modern, type-safe, framework-agnostic, and actively maintained. Much simpler than NextAuth/Auth.js.


Quick Overview

Supported methods:

  • ✅ Email + Password
  • ✅ OAuth (GitHub, Google)
  • ✅ Magic Links (email-only login) - configurable
  • ✅ Session management
  • ✅ Email verification - optional

How it works:

  1. User signs up/logs in
  2. Better Auth creates session
  3. Session stored in database + cookie
  4. tRPC checks session for protected routes

Sign Up & Login

Email + Password Flow

Users can create accounts with email and password.

Sign up page: /sign-up (or custom location)

Example component:

'use client';

import { authClient } from '~/lib/auth.client';
import { useState } from 'react';

export function SignUpForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [name, setName] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    
    try {
      await authClient.signUp.email({
        email,
        password,
        name,
      });
      
      // Redirect to dashboard
      window.location.href = '/dashboard';
    } catch (error) {
      console.error('Sign up failed:', error);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Sign Up</button>
    </form>
  );
}

Login is similar - use authClient.signIn.email():

await authClient.signIn.email({
  email,
  password,
});

OAuth (Social Login)

GitHub OAuth

Let users sign in with GitHub.

Step 1: Create GitHub OAuth App

  1. Go to GitHub Settings → Developer Settings
  2. Click "New OAuth App"
  3. Fill in:
    • Application name: Your App Name
    • Homepage URL: http://localhost:3000 (dev) or https://yourapp.com (production)
    • Authorization callback URL: http://localhost:3000/api/auth/callback/github
  4. Click "Register application"
  5. Copy Client ID and Client Secret

Step 2: Add environment variables

.env
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret

Step 3: Use in your app

'use client';

import { authClient } from '~/lib/auth.client';

export function GitHubButton() {
  function handleGitHubLogin() {
    authClient.signIn.social({
      provider: 'github',
      callbackURL: '/dashboard',
    });
  }

  return (
    <button onClick={handleGitHubLogin}>
      <GitHubIcon /> Sign in with GitHub
    </button>
  );
}

Google OAuth

Similar to GitHub:

Step 1: Create Google OAuth credentials

  1. Go to Google Cloud Console
  2. Create project (or select existing)
  3. Enable Google+ API
  4. Go to CredentialsCreate CredentialsOAuth client ID
  5. Choose Web application
  6. Add authorized redirect URI: http://localhost:3000/api/auth/callback/google
  7. Copy Client ID and Client Secret

Step 2: Environment variables

.env
GOOGLE_CLIENT_ID=your_google_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_google_client_secret

Step 3: Use in app

authClient.signIn.social({
  provider: 'google',
  callbackURL: '/dashboard',
});

Session Management

Check if user is logged in

Server-side (tRPC, API routes):

import { auth } from '~/lib/auth';
import { headers } from 'next/headers';

export async function GET() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    return new Response('Unauthorized', { status: 401 });
  }

  return Response.json({ user: session.user });
}

Client-side (React components):

'use client';

import { authClient } from '~/lib/auth.client';
import { useEffect, useState } from 'react';

export function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    authClient.getSession().then((session) => {
      if (session) {
        setUser(session.user);
      }
    });
  }, []);

  if (!user) return <div>Not logged in</div>;

  return <div>Welcome, {user.name}!</div>;
}

Logout

'use client';

import { authClient } from '~/lib/auth.client';

export function LogoutButton() {
  async function handleLogout() {
    await authClient.signOut();
    window.location.href = '/';
  }

  return <button onClick={handleLogout}>Logout</button>;
}

Protected Routes (tRPC)

Use protectedProcedure to require authentication:

import { createTRPCRouter, protectedProcedure } from '../trpc';

export const usersRouter = createTRPCRouter({
  me: protectedProcedure.query(async ({ ctx }) => {
    // ctx.session.user is guaranteed to exist
    return ctx.session.user;
  }),

  updateProfile: protectedProcedure
    .input(z.object({ name: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const userId = ctx.session.user.id;
      
      await ctx.db
        .update(users)
        .set({ name: input.name })
        .where(eq(users.id, userId));
      
      return { success: true };
    }),
});

How it works:

  1. protectedProcedure checks for valid session
  2. If no session → throws error (frontend shows login prompt)
  3. If session exists → ctx.session.user available in handler

Middleware (Page Protection)

Protect entire pages with middleware:

Create src/middleware.ts:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { auth } from '~/lib/auth';

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Protect /dashboard routes
  if (pathname.startsWith('/dashboard')) {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      const url = request.nextUrl.clone();
      url.pathname = '/login';
      url.searchParams.set('from', pathname);
      return NextResponse.redirect(url);
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*'],
};

Now all /dashboard/* routes require login!


Email Verification (Optional)

Enable email verification for new signups:

Step 1: Configure email provider

ProductReady supports any email provider. Example with Resend:

pnpm add resend

Configure in src/lib/auth.ts:

import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true, // ← Enable verification
  },
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) => {
      await resend.emails.send({
        from: 'noreply@yourapp.com',
        to: user.email,
        subject: 'Verify your email',
        html: `Click to verify: <a href="${url}">${url}</a>`,
      });
    },
  },
});

Step 2: Handle verification callback

Better Auth automatically handles the callback at /api/auth/verify-email?token=xxx.

Users click link → Email verified → Can login!


Password Reset

Step 1: Request reset

'use client';

import { authClient } from '~/lib/auth.client';

export function ForgotPasswordForm() {
  const [email, setEmail] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    
    await authClient.forgetPassword({
      email,
      redirectTo: '/reset-password',
    });

    alert('Check your email for reset link!');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        placeholder="Your email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">Send Reset Link</button>
    </form>
  );
}

Step 2: Reset password page

Create /reset-password/page.tsx:

'use client';

import { authClient } from '~/lib/auth.client';
import { useSearchParams } from 'next/navigation';

export default function ResetPasswordPage() {
  const searchParams = useSearchParams();
  const token = searchParams.get('token');
  const [password, setPassword] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    
    await authClient.resetPassword({
      newPassword: password,
      token: token!,
    });

    alert('Password reset! You can now login.');
    window.location.href = '/login';
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="password"
        placeholder="New password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Reset Password</button>
    </form>
  );
}

User Management

Get current user (tRPC)

export const usersRouter = createTRPCRouter({
  me: protectedProcedure.query(async ({ ctx }) => {
    return ctx.session.user;
  }),
});

Call from frontend:

const { data: user } = trpc.users.me.useQuery();

Update user profile

export const usersRouter = createTRPCRouter({
  updateProfile: protectedProcedure
    .input(z.object({
      name: z.string().optional(),
      image: z.string().url().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const userId = ctx.session.user.id;
      
      await ctx.db
        .update(users)
        .set(input)
        .where(eq(users.id, userId));
      
      return { success: true };
    }),
});

Delete account

export const usersRouter = createTRPCRouter({
  deleteAccount: protectedProcedure.mutation(async ({ ctx }) => {
    const userId = ctx.session.user.id;
    
    // Delete user data (cascade deletes related records)
    await ctx.db
      .delete(users)
      .where(eq(users.id, userId));
    
    // Logout
    await authClient.signOut();
    
    return { success: true };
  }),
});

Best Practices

✅ Do

  • Hash passwords - Better Auth does this automatically
  • Use HTTPS in production - Required for secure cookies
  • Set secure session expiry - Default 7 days, configurable
  • Validate emails - Check format with Zod schema
  • Rate limit auth endpoints - Prevent brute force attacks
// Example rate limiting (use Upstash or similar)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 attempts per minute
});

❌ Don't

  • Don't store passwords in plain text - Always hash (Better Auth does this)
  • Don't expose user tokens - Keep server-side only
  • Don't skip email validation - Use proper regex or library
  • Don't allow weak passwords - Enforce minimum length (8+ chars)

Security Checklist

  • BETTER_AUTH_SECRET is random and secure (32+ chars)
  • Different secrets for dev vs production
  • HTTPS enabled in production
  • Session cookies are httpOnly and secure
  • Password minimum length enforced (8+ chars)
  • Rate limiting on auth endpoints
  • Email verification enabled (if required)
  • OAuth callback URLs match exactly
  • CSRF protection enabled (Better Auth default)

Troubleshooting

"Unauthorized" on protected routes

Check:

  1. Are you logged in? Test with authClient.getSession()
  2. Is cookie being sent? Check browser DevTools → Application → Cookies
  3. Is BETTER_AUTH_URL correct? Should match your domain exactly

OAuth not working

Check:

  1. Client ID/Secret in .env (no quotes, no spaces)
  2. Callback URL matches exactly (including http:// or https://)
  3. Redeploy after changing env vars (Vercel)

Session expires too quickly

Extend session duration in src/lib/auth.ts:

export const auth = betterAuth({
  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 days (in seconds)
  },
});

"Cannot read user of undefined"

Session not loaded yet - add loading state:

const [session, setSession] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
  authClient.getSession().then((s) => {
    setSession(s);
    setLoading(false);
  });
}, []);

if (loading) return <div>Loading...</div>;
if (!session) return <div>Not logged in</div>;

Advanced: Custom Auth UI

Build your own auth components with Better Auth primitives:

'use client';

import { authClient } from '~/lib/auth.client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';

export function CustomLoginForm() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError('');
    setLoading(true);

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

      if (result.error) {
        setError(result.error.message);
        return;
      }

      router.push('/dashboard');
    } catch (err) {
      setError('Login failed. Please try again.');
    } finally {
      setLoading(false);
    }
  }

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

Next Steps

📚 More resources:

On this page