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:
- User signs up/logs in
- Better Auth creates session
- Session stored in database + cookie
- 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
- Go to GitHub Settings → Developer Settings
- Click "New OAuth App"
- Fill in:
- Application name: Your App Name
- Homepage URL:
http://localhost:3000(dev) orhttps://yourapp.com(production) - Authorization callback URL:
http://localhost:3000/api/auth/callback/github
- Click "Register application"
- Copy Client ID and Client Secret
Step 2: Add environment variables
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secretStep 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
- Go to Google Cloud Console
- Create project (or select existing)
- Enable Google+ API
- Go to Credentials → Create Credentials → OAuth client ID
- Choose Web application
- Add authorized redirect URI:
http://localhost:3000/api/auth/callback/google - Copy Client ID and Client Secret
Step 2: Environment variables
GOOGLE_CLIENT_ID=your_google_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_google_client_secretStep 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:
protectedProcedurechecks for valid session- If no session → throws error (frontend shows login prompt)
- If session exists →
ctx.session.useravailable 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 resendConfigure 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_SECRETis random and secure (32+ chars) - Different secrets for dev vs production
- HTTPS enabled in production
- Session cookies are
httpOnlyandsecure - 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:
- Are you logged in? Test with
authClient.getSession() - Is cookie being sent? Check browser DevTools → Application → Cookies
- Is
BETTER_AUTH_URLcorrect? Should match your domain exactly
OAuth not working
Check:
- Client ID/Secret in
.env(no quotes, no spaces) - Callback URL matches exactly (including http:// or https://)
- 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
- Build protected tRPC routes
- Add user roles & permissions
- Integrate with payments
- Deploy with OAuth configured
📚 More resources: