Use FGA to authorize high-cardinality data like documents, files, and messages without syncing them as resources.
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:
This guide builds on the patterns in High-cardinality entities.
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.
| Entity | Where | Why |
|---|---|---|
| Workspace | FGA | Long-lived, low cardinality, distinct roles per user |
| Project | FGA | Same as workspace |
| Document | Application database | High volume, frequently created, access inherited from project |
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.
| Name | Slug | Parent |
|---|---|---|
Workspace | workspace | Organization |
Project | project | Workspace |
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.
| Permission | Resource type | Description |
|---|---|---|
document:view | project | View documents |
document:edit | project | Edit documents |
document:create | project | Create new documents |
document:delete | project | Delete 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.
| Role | Scoped to | Permissions |
|---|---|---|
project-editor | project | document:view, document:edit, document:create, document:delete |
project-viewer | project | document: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.
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.
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.
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.
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.
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.
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.
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.
Other “Model your app” guides:
listEffectivePermissions. See Deep Inheritance.check for every CRUD operation. See Protecting API Endpoints.Related reference:
check endpoint reference