Learn when to use FGA for authorization and when to handle access control in your application.
We recommend using FGA for lower-cardinality resources and handling high-cardinality entities in your application. This guide explains why and shows you how.
Lower-cardinality resources like workspaces, projects, and environments are natural boundaries for role assignments:
High-cardinality entities like rows, artifacts, and messages rarely need this. You don’t typically assign someone as a “row editor” or “message viewer” – they inherit access from the container they’re in. Defining roles and permissions for millions of individual messages would be operationally complex without adding value.
Some authorization systems encourage storing entities such as individual files, messages, and rows as relationships in FGA. After working with many customers on authorization design, we’ve found this creates more problems than it solves. High-cardinality entities are created frequently, change often, and exist in volumes that make keeping two systems in sync impractical. Your database is already the source of truth for these entities, and access is almost always inherited from a parent container rather than granted individually.
Sync to FGA (lower cardinality):
Keep in your application (high cardinality):
To check access to high-cardinality entities, your application should traverse up to the nearest FGA-managed parent before making the authorization check:
async function canUserAccessFile( organizationMembershipId: string, fileId: string, ): Promise<boolean> { // 1. Look up the file to find its parent project const file = await db.files.findUnique({ where: { id: fileId } }); if (!file) return false; // 2. Check access at the project level (FGA-managed) const { authorized } = await workos.authorization.check({ organizationMembershipId, permissionSlug: 'project:view', resourceTypeSlug: 'project', resourceExternalId: file.projectId, }); return authorized; }
For deeply nested structures, walk up the hierarchy to find the FGA-managed parent:
async function canUserAccessFolder( organizationMembershipId: string, folderId: string, ): Promise<boolean> { const MAX_DEPTH = 25; const visited = new Set<string>(); let currentFolderId = folderId; // Walk up the folder hierarchy while (visited.size < MAX_DEPTH) { if (visited.has(currentFolderId)) { // Cycle detected return false; } visited.add(currentFolderId); const folder = await db.folders.findUnique({ where: { id: currentFolderId }, }); if (!folder) return false; // Found the FGA-managed parent if (folder.projectId) { const { authorized } = await workos.authorization.check({ organizationMembershipId, permissionSlug: 'project:view', resourceTypeSlug: 'project', resourceExternalId: folder.projectId, }); return authorized; } // Continue up the hierarchy if (!folder.parentFolderId) return false; currentFolderId = folder.parentFolderId; } // Exceeded max depth return false; }
For organization-level permissions, you can skip the FGA API call entirely by checking JWT claims:
async function canUserAccessFile( session: Session, fileId: string, ): Promise<boolean> { // Fast path: check org-level permission from JWT if (session.permissions?.includes('project:view')) { return true; } // Slow path: check resource-level permission via FGA const file = await db.files.findUnique({ where: { id: fileId } }); if (!file) return false; const { authorized } = await workos.authorization.check({ organizationMembershipId: session.organizationMembershipId, permissionSlug: 'project:view', resourceTypeSlug: 'project', resourceExternalId: file.projectId, }); return authorized; }