@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.
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.
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 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")
ifuserId
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
:
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
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
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
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:
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
"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
// ✅ 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:
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
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
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:
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.
// 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.