StarterApp Docs
Packages

@workspace/identity

User authentication and ownership validation for Convex

Reusable identity primitives for Convex functions. Provides authentication checks and ownership assertions with consistent error handling.

Convex Adapter Required

Convex code should import identity helpers from convex/lib/identity.ts, not directly from this package. The adapter wires these primitives to the BetterAuth component and ensures consistent error contracts.

Package Exports

Core Functions

getAuthUserOrThrow()

Retrieves authenticated user or throws error.

Function signature
async function getAuthUserOrThrow<Ctx>(
  ctx: Ctx,
  betterAuthComponent: BetterAuthComponent<Ctx>
): Promise<AuthUser>

type AuthUser = {
  _id?: string;
  userId?: string | null;
  name?: string;
  email?: string;
  [key: string]: unknown;
}

Behavior

  • Returns authenticated user object
  • Throws Error("Authentication required") if unauthenticated
  • Validates userId is non-empty string

getUserDocIdOrThrow()

Retrieves Convex user document ID or throws error.

Function signature
async function getUserDocIdOrThrow<Ctx, TUserId>(
  ctx: Ctx,
  betterAuthComponent: BetterAuthComponent<Ctx>
): Promise<TUserId>

Behavior

  • Returns Convex document ID (_id field)
  • Throws Error("Authentication required") if missing
  • Type-safe return with generic TUserId

assertOwnerByUserId()

Validates record ownership or throws error.

Function signature
function assertOwnerByUserId<T extends { userId: string }>(
  record: T | null,
  currentUserId: string,
  opts?: { concealExistence?: boolean }
): asserts record is T

Behavior

  • Throws Error("Record not found") if record is null
  • Throws Error("Access denied") if userId mismatch
  • TypeScript narrows type via assertion

Heads up

Default error messages do not distinguish between missing records and unauthorized access. This prevents information leakage about resource existence.

Convex Adapter Pattern

Application code uses the adapter in convex/lib/identity.ts:

convex/lib/identity.ts
import {
  getAuthUserOrThrow as identityGetAuthUserOrThrow,
  getUserDocIdOrThrow as identityGetUserDocIdOrThrow,
} from "@workspace/identity/convex";
import { AppError } from "../_helpers/errors";
import { betterAuthComponent } from "../auth";
import type { Id } from "../_generated/dataModel";

export type UserId = Id<"user">;

export async function getUserIdOrThrow(ctx: ConvexDbCtx): Promise<UserId> {
  try {
    const user = await identityGetAuthUserOrThrow(ctx, betterAuthComponent);
    return user.userId as UserId;
  } catch (error) {
    throw new AppError("UNAUTHENTICATED", undefined, { cause: error });
  }
}

export async function getUserDocIdOrThrow(ctx: ConvexDbCtx): Promise<UserId> {
  try {
    return await identityGetUserDocIdOrThrow<ConvexDbCtx, UserId>(
      ctx,
      betterAuthComponent
    );
  } catch (error) {
    throw new AppError("UNAUTHENTICATED", undefined, { cause: error });
  }
}

export function assertOwnerByUserId<T extends { userId: string }>(
  record: T | null,
  currentUserId: UserId,
  opts: { concealExistence?: boolean } = {}
): asserts record is T {
  const concealExistence = opts.concealExistence ?? false;
  if (!record || record.userId !== currentUserId) {
    throw new AppError(concealExistence ? "NOT_FOUND" : "FORBIDDEN");
  }
}

The adapter:

  • Wraps shared primitives with AppError exceptions
  • Specializes return types to Id<"user">
  • Provides concealExistence option for ownership checks

Usage Patterns

Protected Query

convex/projects.ts
import { query } from "./_generated/server";
import { getUserDocIdOrThrow } from "./lib/identity";
import { T } from "./tables";

export const listMyProjects = query({
  args: {},
  handler: async (ctx) => {
    const userId = await getUserDocIdOrThrow(ctx);

    return await ctx.db
      .query(T.projects)
      .withIndex("by_user_id", (q) => q.eq("userId", userId))
      .collect();
  },
});

Protected Mutation with Ownership

convex/documents.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { getUserDocIdOrThrow, assertOwnerByUserId } from "./lib/identity";
import { T, V } from "./tables";

export const updateDocument = mutation({
  args: {
    documentId: V.documentId,
    title: v.string(),
  },
  handler: async (ctx, { documentId, title }) => {
    const userId = await getUserDocIdOrThrow(ctx);

    const document = await ctx.db.get(documentId);
    assertOwnerByUserId(document, userId);

    await ctx.db.patch(documentId, { title, updatedAt: Date.now() });
  },
});

Concealing Resource Existence

Preventing enumeration attacks
export const getPrivateDocument = query({
  args: { documentId: V.documentId },
  handler: async (ctx, { documentId }) => {
    const userId = await getUserDocIdOrThrow(ctx);

    const document = await ctx.db.get(documentId);

    // Returns 404 for both missing and unauthorized
    assertOwnerByUserId(document, userId, { concealExistence: true });

    return document;
  },
});

Without concealExistence, unauthorized users receive different errors for missing vs owned-by-others resources. This leaks information about resource existence.

Error Handling

AppError Integration

The Convex adapter maps identity errors to AppError with HTTP status codes:

convex/_helpers/errors.ts
export type ErrCode =
  | "UNAUTHENTICATED"  // 401
  | "FORBIDDEN"        // 403
  | "NOT_FOUND"        // 404
  | ...

export function getHttpStatus(error: AppError): number {
  switch (error.code) {
    case "UNAUTHENTICATED": return 401;
    case "FORBIDDEN": return 403;
    case "NOT_FOUND": return 404;
    default: return 500;
  }
}

Convex automatically converts these to HTTP responses in client queries.

Client Error Handling

Client component error handling
"use client";

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

export function ProtectedData({ documentId }: { documentId: string }) {
  const document = useQuery(api.documents.getPrivateDocument, {
    documentId,
  });

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

  // Query returns null on error (Convex behavior)
  if (document === null) {
    return <div>Document not found or access denied</div>;
  }

  return <div>{document.title}</div>;
}

Heads up

Convex queries return null for errors. Check for undefined (loading) vs null (error/not found) separately.

Architecture

Throwing vs Returning

Identity functions use throw-based error handling:

Advantages

  • Early exit prevents defensive null checks
  • Type narrowing via TypeScript assertions
  • Consistent error propagation

Pattern

Comparison
// ✅ Throwing pattern (used by package)
const user = await getUserDocIdOrThrow(ctx);
await ctx.db.patch(user, { ... }); // user is always valid

// ❌ Nullable return (not used)
const user = await getUserDocId(ctx);
if (!user) return null;
await ctx.db.patch(user, { ... }); // requires manual check

Context-Based Security

Functions operate on Convex context objects:

Context requirements
type ConvexDbCtx = {
  auth: {
    getUserIdentity: () => Promise<Identity | null>;
  };
  db: DatabaseReader | DatabaseWriter;
}

This ensures:

  • Authentication state comes from verified Convex runtime
  • Cannot be bypassed by manipulating tokens
  • Test utilities can mock contexts

Testing

Mocking Authentication

Test utilities
import { vi } from "vitest";

// Mock authenticated context
const mockAuthCtx = {
  auth: {
    getUserIdentity: vi.fn().mockResolvedValue({
      subject: "user_123",
      tokenIdentifier: "token",
    }),
  },
  db: mockDb,
};

// Mock unauthenticated context
const mockUnauthCtx = {
  auth: {
    getUserIdentity: vi.fn().mockResolvedValue(null),
  },
  db: mockDb,
};

Testing Ownership Assertions

Ownership test
import { assertOwnerByUserId } from "./lib/identity";
import { expect, test } from "vitest";

test("allows owner access", () => {
  const record = { _id: "doc_1", userId: "user_123", title: "Test" };
  expect(() => assertOwnerByUserId(record, "user_123")).not.toThrow();
});

test("denies non-owner access", () => {
  const record = { _id: "doc_1", userId: "user_123", title: "Test" };
  expect(() => assertOwnerByUserId(record, "user_456")).toThrow("Access denied");
});

test("conceals existence when configured", () => {
  const record = { _id: "doc_1", userId: "user_123", title: "Test" };
  expect(() =>
    assertOwnerByUserId(record, "user_456", { concealExistence: true })
  ).toThrow("NOT_FOUND");
});

Integration with BetterAuth

The identity package depends on BetterAuth-Convex integration:

User authenticates via BetterAuth (OAuth or credentials)

BetterAuth sets session cookie with token

Convex requests include token via getToken(createAuth)

betterAuthComponent.getAuthUser(ctx) validates token and returns user

Identity helpers use this user data for authorization

The adapter in convex/lib/identity.ts connects these pieces.

Performance Considerations

Early Authentication Checks

Place authentication at function start:

Fail-fast pattern
export const expensiveOperation = mutation({
  handler: async (ctx, args) => {
    // Authenticate first (cheap)
    const userId = await getUserDocIdOrThrow(ctx);

    // Then expensive work
    const data = await processLargeDataset(args);
    await ctx.db.insert(T.results, { userId, data });
  },
});

This prevents wasted computation on unauthenticated requests.

Caching Considerations

Identity checks are not cached. Every function call re-validates authentication to ensure current state.

This prevents:

  • Revoked sessions from accessing resources
  • Stale authorization after permission changes
  • Race conditions from cached identity data

AI-Assisted Development

AI assistants understand identity patterns for Convex functions:

Natural Language to Secure Code

Describe mutations like "add endpoint to update user settings" and AI generates implementations with proper authentication and ownership checks automatically.

AI-generated pattern
// Prompt: "Create mutation to update user profile"
export const updateProfile = mutation({
  args: {
    name: v.string(),
    bio: v.optional(v.string()),
  },
  handler: async (ctx, { name, bio }) => {
    const userId = await getUserDocIdOrThrow(ctx);

    await ctx.db.patch(userId, {
      name,
      bio,
      updatedAt: Date.now(),
    });

    return { success: true };
  },
});

The AI applies authentication checks and ownership patterns consistently across all generated functions.