Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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.

Authentication is the single most fragile dependency in any web app because every other request depends on it succeeding. When auth breaks, users are not “degraded” — they are locked out. That makes Better Auth misconfigurations production incidents by definition. The library itself is usually correct; what breaks is the boundary between it and your environment: the database schema, the cookie origin, the OAuth callback URL registered with the provider, or the secret used to sign tokens. Each of these is a separate failure surface that can take down login independently.

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

A third factor is environment drift between local, staging, and production. Local development runs on http://localhost:3000 with insecure cookies. Production runs on https://app.example.com with secure: true, sameSite: 'lax', and a parent-domain cookie scope if your auth API is on a subdomain. A configuration that works on every developer’s laptop can still fail the first time it touches a real HTTPS origin because Secure cookies are dropped over HTTP, SameSite: 'none' requires Secure, and cross-subdomain cookies require an explicit domain field. These constraints are enforced by the browser, not by Better Auth, which is why your server logs look clean while users still see null sessions.

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

Production Incident: Users Locked Out After Deploy

The worst auth incident pattern goes like this. A deploy ships at 14:00. Logins continue to work for users with existing sessions because their cookies are still valid. New logins start to fail silently — signIn.email resolves with error.message: "Invalid email or password" even though the credentials are correct. Support tickets stay quiet until existing sessions expire that night. At 22:00, your active user count drops to zero. By the time someone is paged, a full eight hours of new signups and re-logins have been rejected.

The root cause is almost always one of three changes: the BETTER_AUTH_SECRET env var was rotated (which invalidates all signed tokens), the cookie domain shifted (you moved auth from app.example.com to auth.example.com without updating cookie scope), or a database migration ran with subtle column-type drift (UUID vs text for userId references) that made session lookups return zero rows.

The blast radius is total: every user who attempts to log in. Unlike a feature-flag bug, you cannot dark-launch this — auth is in the critical path of every authenticated route. The mitigation has to be fast.

Monitor three signals to catch this within minutes, not hours. First, alert on the rate of successful logins per minute relative to a 24-hour-prior baseline; a 50% drop is enough to page. Second, alert on the ratio of signIn.* calls returning error vs data — a healthy app sits well under 10% error rate; a misconfigured deploy spikes to 100% instantly. Third, run a synthetic login test from an external monitor (Checkly, Pingdom) every 60 seconds against a test account; it is the cheapest detector and it does not depend on real user traffic.

When the incident is live, the recovery order is: roll back the deploy first, investigate second. Restoring auth is more important than understanding why it broke. Keep a tested rollback procedure for BETTER_AUTH_SECRET rotations specifically — you should be able to re-issue all sessions from a known-good state without forcing every user to log in twice.

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.

Session valid on API routes but null in middleware — Next.js middleware runs on the Edge Runtime, which has different cookie parsing behavior than Node.js routes. Use betterFetch with the request’s cookie header passed through explicitly, as shown in Fix 4. Do not call auth.api.getSession() from middleware — it depends on Node APIs.

Cookies set on localhost but not in production — production browsers refuse to set SameSite: 'none' cookies without Secure: true, and Secure: true requires HTTPS. Confirm your origin is HTTPS, confirm BETTER_AUTH_URL starts with https://, and confirm any reverse proxy forwards X-Forwarded-Proto: https so Better Auth sees the request as secure.

OAuth login redirects in a loop — the callback URL registered with the provider returns to a page that requires auth, which redirects to login, which redirects back to the provider. Set callbackURL to a public landing page or a server-rendered page that reads the session and decides where to go. Loops also happen when the session cookie’s domain does not match the host serving the protected page.

For related auth issues, see Fix: Auth.js Not Working, Fix: Lucia Auth Not Working, Fix: Clerk Not Working, and Fix: Next.js 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