WorkOS Docs Homepage
FGA
API referenceDashboardSign In
OverviewOverviewQuick StartQuick StartCore ConceptsResource TypesResource TypesResourcesResourcesRoles and PermissionsRoles and PermissionsAssignmentsAssignmentsHigh-Cardinality EntitiesHigh-Cardinality EntitiesAccess ControlAccess ChecksAccess ChecksResource DiscoveryResource DiscoveryIntegrationsAuthKit IntegrationAuthKit IntegrationStandalone IntegrationStandalone IntegrationIdP Role AssignmentIdP Role AssignmentModel Your AppBasic AppBasic AppDeep InheritanceDeep InheritanceProtecting API EndpointsProtecting API EndpointsHigh-Cardinality DataHigh-Cardinality DataShare ButtonShare ButtonMigration GuidesMigrate from OpenFGAMigrate from OpenFGAMigrate from SpiceDBMigrate from SpiceDBMigrate from Oso CloudMigrate from Oso Cloud
API Reference
API Reference
Events
Events
Integrations
Integrations
Migrate to WorkOS
Migrate to WorkOS
SDKs
SDKs

High-Cardinality Data

Use FGA to authorize high-cardinality data like documents, files, and messages without syncing them as resources.

On this page

  • What you’ll build
  • 1. Decide what goes into FGA
  • 2. Model the hierarchy
  • 3. Define document permissions on the project type
  • 4. Store the parent reference in your database
  • 5. Read a document
  • 6. List documents inside a project
  • 7. List documents across projects
  • 8. Nested folders
  • 9. Cache the parent ID for performance
  • Putting it all together
  • Next steps

What you’ll build

This guide shows how to authorize high-cardinality data – documents, files, messages, rows – without syncing every record into FGA. The pattern keeps the high-volume entity in your database, stores a reference to its nearest FGA-managed parent, and runs every access check against the parent.

The example is a document editor with three layers. Workspaces and projects are modeled in FGA. Documents stay in the application database, with a project_id reference linking each document to the project it belongs to.

organization (implicit root)
└─ workspace ← in FGA
└─ project ← in FGA
└─ document ← in your database

By the end, you’ll have:

  • A workspace and project hierarchy in FGA
  • Document permissions defined on the project resource type
  • A document table in your database that references its FGA-managed parent
  • Read, list, and search handlers that gate on the parent project
  • A clear rule for deciding what belongs in FGA and what stays in the database

This guide builds on the patterns in High-cardinality entities.

1. Decide what goes into FGA

FGA is designed for stable, low-cardinality entities where users hold distinct roles – workspaces, projects, environments. High-cardinality entities like individual documents are different. They are created frequently, exist in volumes of thousands to millions per organization, and access is almost always inherited from a parent container rather than granted individually.

Keep high-cardinality entities in your database and use FGA to authorize their containers. The database stays the source of truth for the entity, FGA stays the source of truth for access. For the full reasoning, see High-cardinality entities.

EntityWhereWhy
WorkspaceFGALong-lived, low cardinality, distinct roles per user
ProjectFGASame as workspace
DocumentApplication databaseHigh volume, frequently created, access inherited from project

2. Model the hierarchy

Create two resource types in the WorkOS Dashboard under Authorization > Resource Types. Documents are not modeled as a resource type – they only exist in the database.

NameSlugParent
WorkspaceworkspaceOrganization
ProjectprojectWorkspace

3. Define document permissions on the project type

Document permissions are conceptually about documents, but they’re defined on the project resource type because that’s the FGA-managed parent. Permission slugs can use any naming convention, so the document: prefix clearly signals intent.

PermissionResource typeDescription
document:viewprojectView documents
document:editprojectEdit documents
document:createprojectCreate new documents
document:deleteprojectDelete documents

Bundle these into project-scoped roles. A user with project-editor on a project can view, edit, create, and delete every document in that project. A user with project-viewer can only read them.

RoleScoped toPermissions
project-editorprojectdocument:view, document:edit, document:create, document:delete
project-viewerprojectdocument:view

Roles assigned higher in the hierarchy inherit these permissions. A workspace-admin role can include all four document permissions, granting access to every document in every project under the workspace.

4. Store the parent reference in your database

Every document row needs a stable reference to the project it belongs to. Use the project’s external ID – the same ID that was used when registering the project with FGA.

documents
├─ id # The document's primary key
├─ project_id # FK to the project (matches the FGA external ID)
├─ title
├─ content
└─ ...

When a document is created, save it with the project ID:

app.post('/projects/:projectId/documents', async (req, res) => {
const { organizationMembershipId } = req.user;
const { projectId } = req.params;
const { authorized } = await workos.authorization.check({
organizationMembershipId,
permissionSlug: 'document:create',
resourceTypeSlug: 'project',
resourceExternalId: projectId,
});
if (!authorized) {
return res.status(404).json({ error: 'Not found' });
}
const document = await db.documents.create({
data: {
projectId,
title: req.body.title,
content: req.body.content,
},
});
return res.json(document);
});

There’s no createResource call for the document because the document is not in FGA. The project ID stored on the row is enough to authorize every subsequent access.

5. Read a document

To read a document, look it up in the database, then check the user’s permission on its parent project.

app.get('/documents/:documentId', async (req, res) => {
const { organizationMembershipId } = req.user;
const { documentId } = req.params;
const document = await db.documents.findUnique({
where: { id: documentId },
});
if (!document) {
return res.status(404).json({ error: 'Not found' });
}
const { authorized } = await workos.authorization.check({
organizationMembershipId,
permissionSlug: 'document:view',
resourceTypeSlug: 'project',
resourceExternalId: document.projectId,
});
if (!authorized) {
return res.status(404).json({ error: 'Not found' });
}
return res.json(document);
});

The check uses the project as the resource and the document permission as the slug. FGA evaluates the user’s role on the project – plus any inherited roles on the workspace or organization – and returns whether the permission applies.

Update and delete handlers follow the same shape with document:edit and document:delete.

6. List documents inside a project

When the user is already on a project page, listing the project’s documents is a database query gated by a single check on the project itself.

app.get('/projects/:projectId/documents', async (req, res) => {
const { organizationMembershipId } = req.user;
const { projectId } = req.params;
const { authorized } = await workos.authorization.check({
organizationMembershipId,
permissionSlug: 'document:view',
resourceTypeSlug: 'project',
resourceExternalId: projectId,
});
if (!authorized) {
return res.status(404).json({ error: 'Not found' });
}
const documents = await db.documents.findMany({
where: { projectId },
});
return res.json(documents);
});

One check authorizes the entire list, no matter how many documents the project contains. There’s no per-document call because every document inherits the same project-level access.

7. List documents across projects

A global search or “all my documents” view spans many projects. The pattern here is the inverse: first ask FGA for every project the user can read, then run a database query scoped to those project IDs.

app.get('/documents', async (req, res) => {
const { organizationMembershipId } = req.user;
const { data: accessibleProjects } = await workos.authorization.listResourcesForMembership(
{
organizationMembershipId,
permissionSlug: 'document:view',
resourceTypeSlug: 'project',
limit: 100,
},
);
const projectIds = accessibleProjects.map((p) => p.externalId);
const documents = await db.documents.findMany({
where: { projectId: { in: projectIds } },
orderBy: { updatedAt: 'desc' },
take: 50,
});
return res.json(documents);
});

This is a single FGA call for the project IDs followed by a single database query. There is no per-document permission check. For very large project counts, page through listResourcesForMembership with the after cursor and stream results to the client.

8. Nested folders

When documents are nested in folders, the folder hierarchy lives in the database. Walk up the folder chain until reaching the FGA-managed parent, then run the check there.

async function findProjectForDocument(documentId) {
const document = await db.documents.findUnique({ where: { id: documentId } });
if (!document) return null;
let currentFolderId = document.folderId;
const visited = new Set();
while (currentFolderId) {
if (visited.has(currentFolderId)) {
return null;
}
visited.add(currentFolderId);
const folder = await db.folders.findUnique({
where: { id: currentFolderId },
});
if (!folder) return null;
if (folder.projectId) {
return folder.projectId;
}
currentFolderId = folder.parentFolderId;
}
return null;
}

Use the returned project ID as the resource in the check call. Cap the traversal depth to guard against cycles and runaway queries. Caching the project ID directly on each document or folder row eliminates the walk entirely.

9. Cache the parent ID for performance

The walk in the previous step works, but it requires database round trips for each folder. A common optimization is to denormalize the FGA-managed parent ID directly onto every descendant row.

documents
├─ id
├─ project_id # Denormalized for fast access checks
├─ folder_id # For UI navigation
└─ ...

When a document or folder is moved between projects, update the project_id on every descendant in the move. The trade-off is more write work on move operations in exchange for one-step authorization on every read.

Putting it all together

The complete flow for high-cardinality data:

1. Model → Workspace and project in FGA; documents stay in your DB
2. Permissions → Define document:* permissions on the project resource type
3. Database → Store the project_id on every document row
4. Read → DB lookup → check(document:view, project) → 404 if not authorized
5. List in proj → check(document:view, project) → DB query
6. List global → listResourcesForMembership(document:view, project) → DB query (IN clause)
7. Nested → Walk up the folder chain to find the FGA-managed parent

The pattern scales because document volume never affects FGA. Adding a million documents costs nothing in FGA – they all inherit access from the same handful of project assignments.

Next steps

Other “Model your app” guides:

  • Basic App – Single resource type with list and detail views. See Basic App.
  • Deep Inheritance – Multi-level hierarchies and listEffectivePermissions. See Deep Inheritance.
  • Protecting API Endpoints – Using check for every CRUD operation. See Protecting API Endpoints.
  • Share Button – A sharing dialog backed by resource discovery and role assignments. See Share Button.

Related reference:

  • High-cardinality entities – Why some entities don’t belong in FGA
  • Resources – Modeling FGA resources
  • Roles and permissions – How inheritance works
  • Access checks – The check endpoint reference
Share ButtonBuild a share dialog that lists current members of a resource and grants or revokes access
Up next
© WorkOS, Inc.
FeaturesAuthKitSingle Sign-OnDirectory SyncAdmin PortalFine-Grained Authorization
DevelopersDocumentationChangelogAPI Status
ResourcesBlogPodcastPricingSecuritySupport
CompanyAboutCustomersCareersLegalPrivacy
© WorkOS, Inc.