Skip to content

Fix: Better Auth Not Working — Login Failing, Session Null, or OAuth Callback Error

FixDevs ·

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 login

Or OAuth callback fails with an error:

Error: Invalid callback URL — or —
Error: OAuth state mismatch

Or the login API returns 500:

await authClient.signIn.email({ email, password });
// Error: relation "user" does not exist

Why 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 URLauthClient sends requests to your auth API. If the baseURL doesn’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 push

Fix 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles