StarterApp Docs
Authentication

Sessions

Session management with BetterAuth

BetterAuth manages user sessions through secure HTTP-only cookies. Sessions last 7 days and refresh automatically during active use.

Session Architecture

Session data flows through three contexts: server components use getCurrentSession(), client hooks use useSession(), and API routes use auth.api.getSession(). Each context provides type-safe session data appropriate for its environment.

Session Structure

The authentication system provides session data in different shapes depending on the context:

Server components receive a UserSession type from getCurrentSession():

export type UserSession = {
  user: {
    id: string;        // BetterAuth user ID
    name: string;
    email: string;
    image?: string;
  };
  session: {
    userDocId: string; // Convex user document ID
    userId: string;    // BetterAuth user ID
  };
};

The session includes both BetterAuth metadata and Convex document identifiers for data queries.

The useSession hook from @workspace/auth/client provides reactive session state:

type SessionData = {
  user: {
    id: string;
    name: string;
    email: string;
    image?: string;
  };
  session: {
    id: string;
    userId: string;
    expiresAt: Date;
  };
};

API routes using auth.api.getSession() receive the same structure as client hooks, compatible with UseAutumn billing integration.

Server-Side Patterns

Server components and route handlers use packages/app-shell/src/lib/auth/server.ts:

Protected Server Component

import { requireUser } from "~/lib/auth/server";

export default async function DashboardPage() {
  const user = await requireUser(); // Redirects to /sign-in if unauthenticated

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

Optional Authentication

import { getCurrentSession } from "~/lib/auth/server";

export default async function HomePage() {
  const session = await getCurrentSession(); // Returns null if unauthenticated

  if (session) {
    return <AuthenticatedView user={session.user} />;
  }

  return <PublicView />;
}

Protected Layout

The dashboard layout in apps/dashboard/app/(dashboard)/layout.tsx protects all nested routes:

export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";

export default async function DashboardRootLayout({ children }: PropsWithChildren) {
  const session = await getCurrentSession();

  if (!session) {
    redirect("/sign-in");
  }

  return (
    <div className="flex min-h-screen">
      <Sidebar user={session.user} />
      <main>{children}</main>
    </div>
  );
}

Cache Configuration

Protected routes must export revalidate = 0, dynamic = 'force-dynamic', and fetchCache = 'force-no-store' to prevent session data from being cached.

Client-Side Patterns

Client components use React hooks from @workspace/auth/client:

Basic Session Hook

"use client";

import { useSession } from "@workspace/auth/client";

export function UserProfile() {
  const { data: session, isPending } = useSession();

  if (isPending) {
    return <div>Loading...</div>;
  }

  if (!session?.user) {
    return <div>Please sign in</div>;
  }

  return (
    <div>
      <img src={session.user.image} alt={session.user.name} />
      <p>{session.user.name}</p>
      <p>{session.user.email}</p>
    </div>
  );
}

Convex Real-Time Auth

For real-time authentication state, use the Convex getCurrentUser query:

"use client";

import { useQuery } from "convex/react";
import { api } from "@convex/_generated/api";

export function LiveUserProfile() {
  const user = useQuery(api.auth.getCurrentUser);

  if (user === undefined) {
    return <div>Loading...</div>;
  }

  if (user === null) {
    return <div>Please sign in</div>;
  }

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>Member since {new Date(user.createdAt).toLocaleDateString()}</p>
    </div>
  );
}

The Convex query returns the combined user object from convex/auth.ts:

export const getCurrentUser = query({
  args: {},
  handler: async (ctx) => {
    const userMetadata = await betterAuthComponent.getAuthUser(ctx);
    if (!userMetadata) return null;

    const user = await ctx.db.get(userMetadata.userId);
    if (!user) return null;

    return { ...user, ...userMetadata }; // Merged data
  },
});

API Route Patterns

API routes use auth.api.getSession() from @workspace/auth/server:

Protected API Endpoint

import { auth } from "@workspace/auth/server";
import { headers } from "next/headers";
import type { NextRequest } from "next/server";

export const runtime = "nodejs";
export const revalidate = 0;
export const dynamic = "force-dynamic";

export async function GET(req: NextRequest) {
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session?.user?.id) {
    return new Response("Unauthorized", { status: 401 });
  }

  // Proceed with authenticated logic
  return Response.json({
    userId: session.user.id,
    name: session.user.name,
  });
}

With CSRF Protection

For mutation endpoints, combine session checks with CSRF validation:

import { auth } from "@workspace/auth/server";
import { secureErrorJson, secureUserJson } from "@workspace/security";
import { headers } from "next/headers";
import { assertOrigin, assertFetchMetadata } from "~/lib/security/csrf";

export async function POST(req: NextRequest) {
  try {
    assertOrigin(req);
    assertFetchMetadata(req);

    const session = await auth.api.getSession({ headers: await headers() });
    if (!session?.user?.id) {
      return secureErrorJson(
        { message: "Authentication required", code: "Unauthorized" },
        { status: 401, userScoped: true }
      );
    }

    // Mutation logic here
    return secureUserJson({ ok: true });
  } catch (error) {
    return secureErrorJson(
      { message: "Request failed", code: "Internal" },
      { status: 500, userScoped: true }
    );
  }
}

Security Pattern

Use secureUserJson and secureErrorJson from @workspace/security for all API responses. These helpers add security headers and prevent information leakage.

Session Lifecycle

Session Creation

Sessions are created when users authenticate via OAuth or credentials through the /api/auth/* endpoints.

BetterAuth stores the session token in an HTTP-only cookie:

  • Development: session cookie
  • Production: __Host-session cookie with Secure flag

Automatic Refresh

Sessions refresh automatically when accessed after the updateAge threshold (24 hours):

session: {
  expiresIn: 60 * 60 * 24 * 7, // 7 days
  updateAge: 60 * 60 * 24,     // Refresh after 24 hours
}

Session Expiration

Sessions expire after 7 days of inactivity. Expired sessions automatically redirect to /sign-in.

Implementation Details

Token Flow

Server components use the getToken pattern from @convex-dev/better-auth/nextjs:

export async function getCurrentSession(): Promise<UserSession | null> {
  const createAuth = await getCreateAuthFunction();
  const token = await getToken(createAuth); // Reads BetterAuth cookie
  if (!token) return null;

  const user = await fetchQuery(api.auth.getCurrentUser, {}, { token });
  if (!user) return null;

  return {
    user: {
      id: user.userId,
      name: user.name,
      email: user.email,
      image: user.image,
    },
    session: {
      userDocId: user._id,
      userId: user.userId,
    },
  };
}

This pattern:

  1. Reads the BetterAuth session cookie
  2. Generates a Convex authentication token
  3. Queries Convex for user data
  4. Returns a typed session object

Never Use HTTP Fetch

Always use getToken(createAuth) followed by fetchQuery. Never make HTTP requests to BetterAuth endpoints from server components.

Provider Setup

Client components require the ConvexBetterAuthProvider wrapper in apps/dashboard/app/providers.tsx:

"use client";

import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { authClient } from "@workspace/auth/client";
import { convexClient } from "@workspace/convex/client";

export function Providers({ children }: React.PropsWithChildren) {
  return (
    <ConvexBetterAuthProvider authClient={authClient} client={convexClient}>
      {children}
    </ConvexBetterAuthProvider>
  );
}

This provider enables useSession() and Convex authentication hooks throughout the client component tree.