Fix: Better Auth Not Working — Login Failing, Session Null, or OAuth Callback Error
Quick Answer
How to fix Better Auth issues — server and client setup, email/password and OAuth providers, session management, middleware protection, database adapters, and plugin configuration.
The Problem
useSession() always returns null after login:
const { data: session } = authClient.useSession();
console.log(session); // null — even after successful loginOr OAuth callback fails with an error:
Error: Invalid callback URL — or —
Error: OAuth state mismatchOr the login API returns 500:
await authClient.signIn.email({ email, password });
// Error: relation "user" does not existWhy This Happens
Better Auth is a TypeScript-first authentication framework that runs on your server and stores data in your database:
- Database tables must be created first — Better Auth stores users, sessions, accounts, and verification tokens in your database. Without running the migration or creating tables, any auth operation fails with “relation does not exist.”
- The client and server must share the same base URL —
authClientsends requests to your auth API. If thebaseURLdoesn’t match where the server handler is mounted, login requests go to the wrong endpoint. - OAuth requires correct callback URLs — each OAuth provider must have the callback URL registered:
{baseURL}/api/auth/callback/{provider}. A mismatch between the registered URL and the actual URL causes state validation failures. - Sessions are stored server-side — Better Auth uses database-backed sessions by default. The client receives a session token via cookies. If cookies are blocked (wrong domain, SameSite, missing secure flag in production), the session appears null.
Fix 1: Server Setup
npm install better-auth// lib/auth.ts — server configuration
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', // 'pg' | 'mysql' | 'sqlite'
}),
// Email/password authentication
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
maxPasswordLength: 128,
requireEmailVerification: false, // Set true in production
},
// OAuth providers
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Refresh session daily
cookieCache: {
enabled: true,
maxAge: 60 * 5, // Cache session cookie for 5 minutes
},
},
// Base URL — must match where you mount the handler
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000',
// Secret for signing tokens
secret: process.env.BETTER_AUTH_SECRET!,
// Trusted origins for CORS
trustedOrigins: ['http://localhost:3000'],
});// app/api/auth/[...all]/route.ts — Next.js App Router handler
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);# Generate database tables
npx better-auth generate # Outputs SQL migrations
npx better-auth migrate # Apply migrations
# Or use Drizzle to push the schema
npx drizzle-kit pushFix 2: Client Setup
// lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
});
export const {
useSession,
signIn,
signUp,
signOut,
} = authClient;// components/LoginForm.tsx
'use client';
import { authClient } from '@/lib/auth-client';
import { useState } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
async function handleEmailLogin(e: React.FormEvent) {
e.preventDefault();
setError('');
const result = await authClient.signIn.email({
email,
password,
});
if (result.error) {
setError(result.error.message);
return;
}
window.location.href = '/dashboard';
}
async function handleGitHubLogin() {
await authClient.signIn.social({
provider: 'github',
callbackURL: '/dashboard',
});
}
async function handleGoogleLogin() {
await authClient.signIn.social({
provider: 'google',
callbackURL: '/dashboard',
});
}
return (
<div>
<form onSubmit={handleEmailLogin}>
<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 && <p className="text-red-500">{error}</p>}
<button type="submit">Sign In</button>
</form>
<div>
<button onClick={handleGitHubLogin}>Sign in with GitHub</button>
<button onClick={handleGoogleLogin}>Sign in with Google</button>
</div>
</div>
);
}Fix 3: Session Access
// Client component — useSession hook
'use client';
import { authClient } from '@/lib/auth-client';
function UserMenu() {
const { data: session, isPending } = authClient.useSession();
if (isPending) return <div>Loading...</div>;
if (!session) {
return <a href="/login">Sign In</a>;
}
return (
<div>
<span>{session.user.name}</span>
<span>{session.user.email}</span>
<button onClick={() => authClient.signOut()}>Sign Out</button>
</div>
);
}// Server component — direct session access
// app/dashboard/page.tsx
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) redirect('/login');
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Email: {session.user.email}</p>
</div>
);
}// API route — check auth
// app/api/protected/route.ts
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 Response.json({ error: 'Unauthorized' }, { status: 401 });
}
return Response.json({ user: session.user });
}Fix 4: Middleware Protection
// middleware.ts
import { betterFetch } from '@better-fetch/fetch';
import { NextResponse, type NextRequest } from 'next/server';
import type { Session } from 'better-auth/types';
export async function middleware(request: NextRequest) {
const { data: session } = await betterFetch<Session>(
'/api/auth/get-session',
{
baseURL: request.nextUrl.origin,
headers: {
cookie: request.headers.get('cookie') || '',
},
},
);
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};Fix 5: Plugins (2FA, Organization, Admin)
npm install better-auth// lib/auth.ts — with plugins
import { betterAuth } from 'better-auth';
import { twoFactor } from 'better-auth/plugins/two-factor';
import { organization } from 'better-auth/plugins/organization';
import { admin } from 'better-auth/plugins/admin';
export const auth = betterAuth({
// ... base config
plugins: [
twoFactor({
issuer: 'My App',
// TOTP configuration
totpOptions: {
period: 30,
digits: 6,
},
}),
organization({
// Allow users to create organizations
allowUserToCreateOrganization: true,
}),
admin({
// Default admin role
defaultRole: 'user',
}),
],
});
// Client — with plugin methods
import { createAuthClient } from 'better-auth/react';
import { twoFactorClient } from 'better-auth/client/plugins';
import { organizationClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL!,
plugins: [
twoFactorClient(),
organizationClient(),
],
});
// Enable 2FA for a user
await authClient.twoFactor.enable({
password: 'current-password',
});
// Verify TOTP code
await authClient.twoFactor.verifyTotp({
code: '123456',
});Fix 6: Sign Up with Custom Fields
// Server — extend user schema
export const auth = betterAuth({
// ... config
user: {
additionalFields: {
role: {
type: 'string',
required: false,
defaultValue: 'user',
input: false, // Can't be set during sign-up
},
displayName: {
type: 'string',
required: false,
},
},
},
});
// Client — sign up
async function handleSignUp() {
const result = await authClient.signUp.email({
email: '[email protected]',
password: 'securepassword123',
name: 'Alice Johnson',
displayName: 'alice_j', // Custom field
});
if (result.error) {
console.error(result.error.message);
return;
}
// User is created and logged in
window.location.href = '/dashboard';
}Still Not Working?
“relation does not exist” on first login — database tables haven’t been created. Run npx better-auth migrate or npx better-auth generate to get the SQL, then apply it to your database. If using Drizzle, push the schema with npx drizzle-kit push.
Session is null after successful login — check that cookies are being set. Open DevTools → Application → Cookies and look for the session cookie. If missing, the baseURL in the server config might not match the actual URL. Also check that BETTER_AUTH_SECRET is set — without it, session tokens can’t be signed.
OAuth returns “invalid callback URL” — register {your-domain}/api/auth/callback/github (or /google) as the callback URL in the OAuth provider’s settings. The path must match exactly. In development, this is http://localhost:3000/api/auth/callback/github.
Login works but useSession() is slow — enable cookieCache in the session config. Without caching, every useSession() call makes a request to the server. With cookieCache: { enabled: true, maxAge: 300 }, the session is cached in a cookie for 5 minutes.
For related auth issues, see Fix: Auth.js Not Working and Fix: Lucia Auth Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Clerk Not Working — Auth Not Loading, Middleware Blocking, or User Data Missing
How to fix Clerk authentication issues — ClerkProvider setup, middleware configuration, useUser and useAuth hooks, server-side auth, webhook handling, and organization features.
Fix: next-safe-action Not Working — Action Not Executing, Validation Errors Missing, or Type Errors
How to fix next-safe-action issues — action client setup, Zod schema validation, useAction and useOptimisticAction hooks, middleware, error handling, and authorization patterns.
Fix: ts-rest Not Working — Contract Types Not Matching, Client Requests Failing, or Server Validation Errors
How to fix ts-rest issues — contract definition, type-safe client and server setup, Zod validation, Next.js App Router integration, error handling, and OpenAPI generation.
Fix: Auth.js (NextAuth) Not Working — Session Null, OAuth Callback Error, or CSRF Token Mismatch
How to fix Auth.js and NextAuth.js issues — OAuth provider setup, session handling in App Router and Pages Router, JWT vs database sessions, middleware protection, and credential provider configuration.