Skip to main content

Overview

Zen Nurture uses Better Auth integrated with Convex for authentication. Better Auth provides a flexible authentication system with support for email/password, OAuth providers, and session management.

Authentication Flow

The authentication flow consists of:
  1. User signs in via Better Auth (email/password, OAuth, etc.)
  2. Better Auth creates a session stored in the database
  3. Client requests Convex token via /api/auth/convex/token
  4. Convex validates the token on each request
  5. Protected queries/mutations check authentication using requireAuth()
1

Configure Better Auth

Set up Better Auth with the Convex adapter:
convex/auth.ts
import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { betterAuth } from "better-auth/minimal";
import type { DataModel } from "./_generated/dataModel";
import { components } from "./_generated/api";
import authConfig from "./auth.config";

export const authComponent = createClient<DataModel>(components.betterAuth);

export const createAuth = (ctx: GenericCtx<DataModel>) => {
  const baseURL = process.env.SITE_URL!;
  const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
    ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",").map((s) => s.trim())
    : [baseURL, "http://localhost:3000"];
  const isLocal =
    baseURL.includes("localhost") || baseURL.includes("127.0.0.1");

  return betterAuth({
    baseURL,
    trustedOrigins,
    advanced: {
      disableOriginCheck: isLocal,
    },
    database: authComponent.adapter(ctx),
    emailAndPassword: {
      enabled: true,
      requireEmailVerification: false,
    },
    plugins: [convex({ authConfig })],
  });
};

export const { getAuthUser } = authComponent.clientApi();
2

Configure Auth Config

Create the auth configuration:
convex/auth.config.ts
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";

export default {
  providers: [getAuthConfigProvider()],
} satisfies AuthConfig;
3

Register HTTP Routes

Register Better Auth routes in your HTTP router:
convex/http.ts
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";

const http = httpRouter();

authComponent.registerRoutes(http, createAuth);

export default http;
4

Set Up Client

Configure the Better Auth client with Convex plugin:
src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { convexClient } from "@convex-dev/better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [convexClient()],
});

Session Management

Better Auth manages sessions automatically. Sessions are stored in the Convex database and validated on each request.

Check Authentication Status

import { authClient } from "@/lib/auth-client";

function MyComponent() {
  const { data: session, isPending } = authClient.useSession();

  if (isPending) return <div>Loading...</div>;
  if (!session) return <div>Not logged in</div>;

  return <div>Welcome, {session.user.name}!</div>;
}

Sign In

const { signIn } = authClient;

await signIn.email(
  {
    email: "[email protected]",
    password: "password123",
  },
  {
    onSuccess: () => {
      console.log("Signed in successfully");
    },
    onError: (error) => {
      console.error("Sign in failed:", error);
    },
  }
);

Sign Out

const { signOut } = authClient;

await signOut();

Protected Queries and Mutations

Zen Nurture uses the requireAuth helper to protect API endpoints:
convex/lib/auth.ts
import type { Id } from "../_generated/dataModel";
import type { QueryCtx, MutationCtx } from "../_generated/server";
import { authComponent } from "../auth";

type Ctx = QueryCtx | MutationCtx;

export async function requireAuth(ctx: Ctx) {
  const user = await authComponent.safeGetAuthUser(ctx);
  if (!user) throw new Error("Unauthenticated");
  return user;
}

export async function getUserFamilyIds(ctx: Ctx, userId: string) {
  const memberships = await ctx.db
    .query("familyMembers")
    .withIndex("by_userId", (q) => q.eq("userId", userId))
    .collect();
  return memberships.map((m) => m.familyId);
}

export async function requireBabyAccess(
  ctx: Ctx,
  babyId: Id<"babyProfiles">,
  userId: string
): Promise<void> {
  const baby = await ctx.db.get(babyId);
  if (!baby?.familyId) throw new Error("Baby not found");
  const familyIds = await getUserFamilyIds(ctx, userId);
  if (!familyIds.includes(baby.familyId)) throw new Error("Not authorized");
}

Using requireAuth in Queries

convex/events.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { authComponent } from "./auth";
import { requireBabyAccess } from "./lib/auth";

export const listEvents = query({
  args: {
    babyId: v.id("babyProfiles"),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    // Check authentication
    const user = await authComponent.safeGetAuthUser(ctx);
    if (!user) return [];
    
    // Check authorization for this baby
    await requireBabyAccess(ctx, args.babyId, user._id);
    
    // Fetch data
    return await ctx.db
      .query("events")
      .withIndex("by_babyId_timestamp", (q) => q.eq("babyId", args.babyId))
      .order("desc")
      .take(args.limit || 100);
  },
});

Using requireAuth in Mutations

convex/events.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { requireAuth, requireBabyAccess } from "./lib/auth";

export const createEvent = mutation({
  args: {
    babyId: v.id("babyProfiles"),
    type: v.string(),
    timestamp: v.string(),
    payload: v.optional(v.any()),
  },
  handler: async (ctx, args) => {
    // Require authentication
    const user = await requireAuth(ctx);
    
    // Check baby access
    await requireBabyAccess(ctx, args.babyId, user._id);
    
    // Create event
    const id = await ctx.db.insert("events", {
      ...args,
      source: "manual",
      createdAt: new Date().toISOString(),
      loggedBy: user._id,
      loggedByName: user.name,
    });
    
    return id;
  },
});

Token Handling

The Convex client automatically fetches and refreshes tokens:
ConvexClientProvider.tsx
function useAuthFromBetterAuth() {
  const { data: session, isPending } = authClient.useSession();

  const fetchAccessToken = useCallback(
    async ({ forceRefreshToken }: { forceRefreshToken: boolean }) => {
      try {
        const response = await fetch("/api/auth/convex/token", {
          credentials: "include",
        });
        if (!response.ok) return null;
        const { token } = await response.json();
        return token ?? null;
      } catch {
        return null;
      }
    },
    []
  );

  return {
    isLoading: isPending,
    isAuthenticated: !!session,
    fetchAccessToken,
  };
}
The token is automatically included in all Convex queries and mutations when using ConvexProviderWithAuth.

Authorization Patterns

Zen Nurture implements resource-level authorization:

Family-Based Access

Users can only access babies in families they belong to:
export async function requireBabyAccess(
  ctx: Ctx,
  babyId: Id<"babyProfiles">,
  userId: string
): Promise<void> {
  const baby = await ctx.db.get(babyId);
  if (!baby?.familyId) throw new Error("Baby not found");
  
  const familyIds = await getUserFamilyIds(ctx, userId);
  if (!familyIds.includes(baby.familyId)) {
    throw new Error("Not authorized");
  }
}

Role-Based Access

Family operations check user roles:
convex/families.ts
export const inviteCaregiver = mutation({
  args: {
    familyId: v.id("families"),
    email: v.string(),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);

    const membership = await ctx.db
      .query("familyMembers")
      .withIndex("by_familyId_userId", (q) =>
        q.eq("familyId", args.familyId).eq("userId", user._id)
      )
      .first();

    if (!membership || !["owner", "admin"].includes(membership.role)) {
      throw new Error("Only owners and admins can invite caregivers");
    }

    // Create invitation...
  },
});

Security Best Practices

Never skip authentication checks in production. Always validate user access before returning sensitive data.
  1. Always use requireAuth for mutations that modify data
  2. Check resource ownership with requireBabyAccess or similar helpers
  3. Validate user roles for administrative operations
  4. Use safeGetAuthUser for queries that should return empty results instead of errors
  5. Set CORS origins properly in production via BETTER_AUTH_TRUSTED_ORIGINS

Environment Variables

Required environment variables:
.env.local
# Convex
NEXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud

# Better Auth
SITE_URL=https://your-site.com
BETTER_AUTH_SECRET=your-secret-key
BETTER_AUTH_TRUSTED_ORIGINS=https://your-site.com

Error Handling

Authentication errors should be handled gracefully:
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";

function CreateBabyProfile() {
  const createProfile = useMutation(api.events.createBabyProfile);

  const handleSubmit = async (data: any) => {
    try {
      await createProfile(data);
    } catch (error) {
      if (error.message === "Unauthenticated") {
        // Redirect to login
        window.location.href = "/login";
      } else if (error.message === "Not authorized") {
        // Show access denied message
        alert("You don't have access to this resource");
      } else {
        // Handle other errors
        console.error(error);
      }
    }
  };
}

Next Steps

API Overview

Learn about the Zen Nurture API architecture

Baby Profiles

Create and manage baby profiles

Events API

Track feeding, diaper changes, sleep, and more

Better Auth Docs

Official Better Auth documentation

Build docs developers (and LLMs) love