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:
User signs in via Better Auth (email/password, OAuth, etc.)
Better Auth creates a session stored in the database
Client requests Convex token via /api/auth/convex/token
Convex validates the token on each request
Protected queries/mutations check authentication using requireAuth()
Configure Better Auth
Set up Better Auth with the Convex adapter: 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 ();
Configure Auth Config
Create the auth configuration: import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config" ;
import type { AuthConfig } from "convex/server" ;
export default {
providers: [ getAuthConfigProvider ()] ,
} satisfies AuthConfig ;
Register HTTP Routes
Register Better Auth routes in your HTTP router: import { httpRouter } from "convex/server" ;
import { authComponent , createAuth } from "./auth" ;
const http = httpRouter ();
authComponent . registerRoutes ( http , createAuth );
export default http ;
Set Up Client
Configure the Better Auth client with Convex plugin: 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:
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
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
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:
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:
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.
Always use requireAuth for mutations that modify data
Check resource ownership with requireBabyAccess or similar helpers
Validate user roles for administrative operations
Use safeGetAuthUser for queries that should return empty results instead of errors
Set CORS origins properly in production via BETTER_AUTH_TRUSTED_ORIGINS
Environment Variables
Required environment variables:
# 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