Skip to content

Better Auth

Better Auth support lives in its own entry point:

import { injectBetterAuth, provideBetterAuth } from 'convex-angular/better-auth';

Unlike Clerk and Auth0, this integration does more than adapt signals: it owns session tracking, the Convex token exchange, token caching, and re-auth on session change.

function provideBetterAuth(clientFactory: () => BetterAuthClientLike): EnvironmentProviders;

Pass a factory that returns your Better Auth client. The factory is invoked lazily — only when the session is first needed — and only in the browser. It never runs on the server; for authenticated SSR use provideConvex(...)’s ssr.authToken option instead.

You own the client. Create exactly one instance and use it for both the library and your own sign-in, sign-up, and sign-out flows:

auth-client.ts
import { convexClient, crossDomainClient } from '@convex-dev/better-auth/client/plugins';
import { createAuthClient } from 'better-auth/client';
import { environment } from '../environments/environment';
function buildAuthClient() {
return createAuthClient({
baseURL: environment.convexSiteUrl,
plugins: [convexClient(), crossDomainClient({ storagePrefix: 'my-app' })],
});
}
export type AppAuthClient = ReturnType<typeof buildAuthClient>;
let instance: AppAuthClient | null = null;
/** One instance shared by provideBetterAuth() and the app's sign-in flows. */
export function getAuthClient(): AppAuthClient {
instance ??= buildAuthClient();
return instance;
}

A second instance would keep its own session cache and quietly diverge from the one the library reads.

Register it once:

app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideConvex } from 'convex-angular';
import { BetterAuthClientLike, provideBetterAuth } from 'convex-angular/better-auth';
// import { authClient } from './auth-client';
//
// These docs do not depend on `better-auth`, so the shared client instance is
// declared here. A client built with
// `createAuthClient({ plugins: [convexClient(), crossDomainClient()] })`
// structurally satisfies `BetterAuthClientLike`.
declare const authClient: BetterAuthClientLike;
export const appConfig: ApplicationConfig = {
providers: [
provideConvex('https://example-123.convex.cloud'),
// The factory is invoked lazily and only in the browser — it never runs
// during server-side rendering. Already includes provideConvexAuth().
provideBetterAuth(() => authClient),
],
};

Sign-in, sign-up, and sign-out stay on your client

Section titled “Sign-in, sign-up, and sign-out stay on your client”

The library never calls your auth flows. Run them on your own client instance, then tell the library to re-read the session:

  • After a successful sign-in or sign-up: await betterAuth.refreshSession().
  • After sign-out: betterAuth.clearSession().
auth-flows.service.ts
import { Component, Injectable, inject, signal } from '@angular/core';
import { BetterAuthClientLike, injectBetterAuth } from 'convex-angular/better-auth';
/**
* Your real client exposes the sign-in/up/out flows on top of the structural
* surface convex-angular needs. Declared here because these docs do not depend
* on `better-auth`; in your app this is `import { authClient } from './auth-client'`.
*/
type AppAuthClient = BetterAuthClientLike & {
signIn: {
email(input: {
email: string;
password: string;
fetchOptions?: { throw?: boolean };
}): Promise<{ error: { message?: string; status?: number } | null }>;
};
signOut(input?: {
fetchOptions?: { throw?: boolean };
}): Promise<{ error: { message?: string; status?: number } | null }>;
};
declare const authClient: AppAuthClient;
@Injectable({ providedIn: 'root' })
export class AuthFlowsService {
// Session and Convex token state live in the library; the flows stay on your
// own client instance.
private readonly betterAuth = injectBetterAuth();
readonly formError = signal<string | null>(null);
async signIn(email: string, password: string): Promise<boolean> {
this.formError.set(null);
const result = await authClient.signIn.email({ email, password, fetchOptions: { throw: false } });
if (result.error) {
this.formError.set(result.error.message ?? 'Unable to sign in with those credentials.');
return false;
}
// Re-sync the session so Convex picks up the new token.
await this.betterAuth.refreshSession();
return this.betterAuth.isAuthenticated();
}
async signOut(): Promise<void> {
try {
await authClient.signOut({ fetchOptions: { throw: false } });
} finally {
// Local bookkeeping: drops the session snapshot and the token cache.
this.betterAuth.clearSession();
}
}
}
@Component({
selector: 'app-session-banner',
template: `
@if (betterAuth.isLoading()) {
<p>Loading session…</p>
} @else if (betterAuth.session(); as session) {
<p>Signed in as {{ session.user.id }}</p>
<button type="button" (click)="flows.signOut()">Sign out</button>
} @else {
<p>Signed out.</p>
}
@if (betterAuth.error(); as error) {
<p role="alert">{{ error.message }}</p>
}
`,
})
export class SessionBannerComponent {
readonly betterAuth = injectBetterAuth();
readonly flows = inject(AuthFlowsService);
}
function injectBetterAuth(options?: InjectBetterAuthOptions): BetterAuthState;
interface InjectBetterAuthOptions {
injectRef?: EnvironmentInjector;
}

Called without provideBetterAuth(...) registered, it throws:

Could not find BetterAuthService. Call provideBetterAuth(...) in your providers, or pass { injectRef } when calling injectBetterAuth() outside an injection context.
interface BetterAuthState {
session: Signal<BetterAuthSessionData | null>;
isLoading: Signal<boolean>;
isAuthenticated: Signal<boolean>;
error: Signal<Error | undefined>;
refreshSession(): Promise<void>;
clearSession(): void;
}
Member Type Description
session Signal<BetterAuthSessionData | null> The current session snapshot, or null when signed out.
isLoading Signal<boolean> true while the session is being loaded or re-loaded. Set to false immediately on the server.
isAuthenticated Signal<boolean> true when a session is present.
error Signal<Error | undefined> The most recent unexpected session or token-exchange failure. Session and token failures are tracked independently and the newer one wins.
refreshSession() () => Promise<void> Re-syncs the session from Better Auth. Call after a sign-in/up/out flow completes. No-op on the server.
clearSession() () => void Local sign-out bookkeeping: clears the session snapshot and the Convex token cache, and notifies the client’s session listeners.

BetterAuthState is also what backs injectAuth(): the same service implements ConvexAuthProvider, so Convex-level state (status(), isRefreshing(), getAuth()) comes from injectAuth() while session detail comes from injectBetterAuth().

A 401 or 403 from getSession() or from the Convex token exchange is the expected signed-out outcome. The session is cleared and error() stays undefined. Anything else — a network failure, a 500, a thrown exception — sets error():

Failing path Message prefix
getSession() [convex-angular better-auth] Session refresh failed:
convex.token() [convex-angular better-auth] Convex token exchange failed:

On a 401/403 the library also refuses to fall back to the cross-domain plugin’s cached getSessionData(), because that cache is only written on a successful response and can otherwise hand back a stale, already-invalid session.

The complete surface the library uses. A client created with createAuthClient({ plugins: [convexClient(), crossDomainClient()] }) satisfies it.

interface BetterAuthClientLike {
getSession(options?: { fetchOptions?: { throw?: boolean } }): Promise<BetterAuthFetchResult<BetterAuthSessionData>>;
/** Provided by the convexClient() plugin: exchanges the session for a Convex JWT. */
convex: {
token(options?: { fetchOptions?: { throw?: boolean } }): Promise<BetterAuthFetchResult<{ token?: string | null }>>;
};
/** Cross-domain plugin extras; used when present. */
getSessionData?(): BetterAuthSessionData | null;
updateSession?(): void;
}
Member Required Used for
getSession yes Reading the session on startup and on every refreshSession().
convex.token yes Exchanging the session for the JWT Convex validates. Comes from the convexClient() plugin.
getSessionData no Fallback session snapshot when getSession() returns no data and no error. Skipped on 401/403.
updateSession no Notifying the client’s own session listeners during clearSession().
interface BetterAuthSessionData {
session: { id: string; expiresAt?: string | Date };
user: { id: string } & Record<string, unknown>;
}

Extra fields on user are preserved but untyped. session.id is what the library watches: when it changes, the token cache is dropped and Convex auth setup re-runs.

Better Auth’s { data, error } envelope.

interface BetterAuthFetchResult<T> {
data: T | null;
error: { message?: string; status?: number } | null;
}

The library always calls with fetchOptions: { throw: false } so failures arrive as error rather than exceptions.