TL;DR
- OpenFGA is an open-source authorization engine inspired by Google’s Zanzibar paper. It answers one small but powerful question: “Can this user, service, or agent perform this relation on this object?”
- Instead of hiding permissions inside app code, OpenFGA lets you model users, teams, organizations, resources, roles, inheritance, sharing, and delegation as relationships.
- In modern web apps, OpenFGA works best as a server-side authorization layer behind your API routes, loaders, server actions, GraphQL resolvers, and background jobs.
- For MCP servers and agent tools, OpenFGA is especially useful because tool access is rarely just “is logged in.” It is usually “can this user use this tool on this workspace, project, repository, ticket, customer, or document?”
- For workflow agents and orchestrators, OpenFGA can separate the human who initiated work, the agent identity doing the work, the task being executed, and the resource being touched.
- Do not use OpenFGA as a replacement for authentication, database row-level security, audit logs, or human approval. Use it as the policy brain that those layers call.
Authorization gets complicated exactly when your product starts getting useful.
At the beginning, “admin” and “member” feel fine. Then customers ask for workspaces, teams, guests, project-level roles, document sharing, billing-only users, support impersonation, read-only API keys, automation identities, MCP tools, and AI agents that can act on behalf of people.
Suddenly the innocent-looking question “can this person click this button?” turns into:
Can Luis approve this deployment
inside Acme's production project
through this workflow agent
after the ticket has been approved
but without letting the agent read billing data?
That is the world OpenFGA was built for.
What You Will Learn Here
- The mental model behind OpenFGA and relationship-based access control
- How to design your first authorization model
- How to use OpenFGA from a modern web app
- Where OpenFGA belongs in a stack with OAuth, databases, MCP, and agent tools
- How to model MCP tool permissions, RAG document access, workflow agents, and task-scoped delegation
- The production gaps you still need to cover around caching, audit, migrations, testing, and approvals
OpenFGA in One Sentence
OpenFGA stores relationships, evaluates an authorization model, and tells your app whether a subject has a permission on an object.
That sounds small. It is not.
The object can be a document, project, workspace, repository, customer, invoice, tool, task, workflow, environment, or memory record.
The subject can be a human user, a team, an organization, a service account, an API key, an MCP client, or an agent.
The permission can be direct, inherited, computed, delegated, or conditional.
Subject Relation Object
------- -------- ------
user:luis ---> owner ---> organization:acme
team:eng ---> member ---> organization:acme
user:ana ---> viewer ---> document:roadmap
agent:triage ---> can_read ---> ticket:123
tool:refund ---> available_in ---> workspace:acme
The app asks:
Can user:luis view document:roadmap?
Can agent:triage summarize ticket:123?
Can user:ana invoke tool:refund in workspace:acme?
OpenFGA answers allowed: true or allowed: false.
Why This Matters Now
Fine-grained authorization used to be a “large SaaS company problem.” Now it shows up much earlier because apps are more collaborative and agents are touching real systems.
A modern product may have:
- user-facing dashboards
- API keys
- webhooks
- background jobs
- admin consoles
- embedded copilots
- MCP servers
- workflow agents
- data connectors
- RAG over customer documents
- human approval steps
Each surface needs authorization, but not always the same kind.
Authentication answers:
Who are you?
OAuth scopes often answer:
What broad category of access did you request?
OpenFGA answers:
Given this exact user, resource, relation, and context, is this action allowed?
That last question is where most product complexity lives.
The Core Concepts
OpenFGA has four concepts you need to internalize.
| Concept | Plain meaning | Example |
|---|---|---|
| Type | A kind of thing in your system | user, organization, project, document |
| Relation | A named relationship or permission | owner, member, viewer, can_edit |
| Tuple | A stored relationship fact | user:luis is owner of organization:acme |
| Check | A permission question | Can user:luis can_edit document:roadmap? |
The authorization model defines what relations exist and how they compose.
The relationship tuples are the data.
Your application performs checks at runtime.
Application code
|
| "Can user:123 can_edit document:456?"
v
OpenFGA
|
| evaluates model + tuples + optional context
v
allowed true/false
A First Model: Organizations, Projects, and Documents
Let’s model a common SaaS app:
- Organizations contain projects.
- Projects contain documents.
- Organization admins can manage everything under the organization.
- Project editors can edit project documents.
- Project viewers can view project documents.
- A document can also be shared directly with a user.
An OpenFGA model can look like this:
model
schema 1.1
type user
type organization
relations
define admin: [user]
define member: [user]
type project
relations
define parent: [organization]
define owner: [user] or admin from parent
define editor: [user] or owner
define viewer: [user] or editor or member from parent
define can_manage: owner
define can_edit: editor
define can_view: viewer
type document
relations
define parent: [project]
define direct_viewer: [user]
define direct_editor: [user]
define can_edit: direct_editor or can_edit from parent
define can_view: direct_viewer or direct_editor or can_view from parent
That compact model carries a lot of product behavior:
- organization admins inherit ownership of projects
- project owners can edit
- project editors can view
- organization members can view projects
- documents inherit permissions from the project
- documents can still be shared directly
Now add relationship tuples:
organization:acme#admin@user:luis
organization:acme#member@user:ana
project:agent-platform#parent@organization:acme
project:agent-platform#editor@user:maya
document:roadmap#parent@project:agent-platform
document:roadmap#direct_viewer@user:guest-42
The interesting part is what you do not have to store.
You do not need to store user:luis can_edit document:roadmap. That is inferred from:
Luis is admin of organization:acme
project:agent-platform belongs to organization:acme
document:roadmap belongs to project:agent-platform
project admins can edit project documents
This is the main unlock: you store durable relationship facts, and the authorization engine derives permissions from the model.
How This Fits in a Web App
OpenFGA should sit behind your server boundary.
Do not let the browser ask OpenFGA directly. The browser is not trusted. Your API, server action, route handler, or backend service should authenticate the user, map the app action to an OpenFGA relation, run the check, and only then execute the action.
Browser
|
| request with session/JWT
v
Web app server
|
| authenticate user
| map route to relation
| check OpenFGA
v
Business logic
|
| query/update database
v
Response
A small TypeScript helper might look like this:
import { OpenFgaClient } from "@openfga/sdk";
const fga = new OpenFgaClient({
apiUrl: process.env.FGA_API_URL!,
storeId: process.env.FGA_STORE_ID!,
authorizationModelId: process.env.FGA_MODEL_ID!,
});
type CheckInput = {
userId: string;
relation: string;
object: string;
};
export async function assertAllowed(input: CheckInput) {
const result = await fga.check({
user: `user:${input.userId}`,
relation: input.relation,
object: input.object,
});
if (!result.allowed) {
throw new Response("Forbidden", { status: 403 });
}
}
Use it at the boundary:
export async function updateDocumentTitle(request: Request, documentId: string) {
const session = await requireSession(request);
await assertAllowed({
userId: session.user.id,
relation: "can_edit",
object: `document:${documentId}`,
});
return db.document.update({
where: { id: documentId },
data: { title: await readTitle(request) },
});
}
The user interface can use permission checks to hide or disable buttons, but that is only ergonomics. The server check is the security boundary.
Check, Batch Check, and List Objects
Most OpenFGA integration work becomes one of three query shapes.
Check
Use Check when one request needs one decision.
Can user:luis can_view document:roadmap?
Good for:
- opening a document
- editing a project setting
- invoking one MCP tool
- approving one workflow step
Batch Check
Use batch checks when one screen needs many decisions.
Can user:luis can_edit document:1?
Can user:luis can_delete document:1?
Can user:luis can_invite project:alpha?
Can user:luis can_export project:alpha?
Good for:
- rendering action menus
- determining feature controls
- checking multiple tool permissions at once
List Objects
Use list objects when you need to ask:
Which documents can user:luis view?
Good for:
- resource pickers
- search filters
- document lists
- agent retrieval filters
Be careful with list endpoints in large tenants. You still need database filters, pagination, search indexes, and sometimes denormalized permission indexes depending on scale and latency requirements.
Concrete Design: A Mini Google Docs App
Let’s make this less abstract.
Imagine a simple collaborative document app. Not a full Google Docs clone, but close enough to expose the real permission layers:
- users belong to organizations
- users create documents
- document owners can share with viewers, commenters, and editors
- organization admins can manage documents in the organization
- API keys can read or edit selected documents
- the UI should hide actions the user cannot perform
- the API must enforce permissions even if someone calls it directly
- the database remains the source of document content
- OpenFGA remains the source of relationship permissions
The app has four important data sources:
| Data source | What lives there |
|---|---|
| Auth provider | user identity, sessions, JWTs |
| Postgres | organizations, users, documents, document revisions, comments, API keys, audit events |
| OpenFGA | relationships: owner, editor, commenter, viewer, organization admin, API-key access |
| Object storage or search index | optional exports, attachments, full-text search, embeddings |
The request path looks like this:
Browser / API client
|
| session cookie or API key
v
App API
|
| authenticate principal
| user:luis or api_key:docs-importer
v
Authorization middleware
|
| OpenFGA check
| principal can_edit document:doc_123
v
Business logic
|
| read/write Postgres
| write audit event
v
Response
The important separation:
Postgres stores document state.
OpenFGA stores who can do what.
The API is the only place where both are joined.
Product Permissions
For a document app, a practical permission matrix might look like this:
| Role or relation | View | Comment | Edit | Share | Delete |
|---|---|---|---|---|---|
| Organization admin | yes | yes | yes | yes | yes |
| Document owner | yes | yes | yes | yes | yes |
| Document editor | yes | yes | yes | no | no |
| Document commenter | yes | yes | no | no | no |
| Document viewer | yes | no | no | no | no |
| API key viewer | yes | no | no | no | no |
| API key editor | yes | no | yes | no | no |
This is already more granular than “admin/member.” It also makes a useful product decision: API keys can read and edit documents, but cannot share or delete them.
That limit matters. API keys are usually long-lived, copied into CI systems, and used by scripts. They should not inherit every power a human owner has.
OpenFGA Model
Here is a compact model for the app:
model
schema 1.1
type user
type api_key
type organization
relations
define admin: [user]
define member: [user]
type document
relations
define parent: [organization]
define owner: [user]
define editor: [user, api_key]
define commenter: [user]
define viewer: [user, api_key]
define can_view: viewer or commenter or editor or owner or admin from parent
define can_comment: commenter or editor or owner or admin from parent
define can_edit: editor or owner or admin from parent
define can_share: owner or admin from parent
define can_delete: owner or admin from parent
Now seed relationships:
organization:acme#admin@user:luis
organization:acme#member@user:ana
organization:acme#member@user:maya
document:roadmap#parent@organization:acme
document:roadmap#owner@user:luis
document:roadmap#editor@user:maya
document:roadmap#commenter@user:ana
document:roadmap#viewer@api_key:docs-importer
document:release-notes#parent@organization:acme
document:release-notes#owner@user:maya
document:release-notes#editor@api_key:release-bot
Those tuples create these outcomes:
user:luis can_share document:roadmap true
user:maya can_edit document:roadmap true
user:ana can_comment document:roadmap true
user:ana can_edit document:roadmap false
api_key:docs-importer can_view roadmap true
api_key:docs-importer can_delete roadmap false
api_key:release-bot can_edit release-notes true
api_key:release-bot can_share release-notes false
That is the shape you want: humans, API keys, and derived permissions all use the same authorization language.
Postgres Schema
OpenFGA does not store your document content. Keep that in your application database.
create table organizations (
id text primary key,
name text not null,
created_at timestamptz not null default now()
);
create table users (
id text primary key,
email text not null unique,
name text not null,
created_at timestamptz not null default now()
);
create table documents (
id text primary key,
organization_id text not null references organizations(id),
title text not null,
body jsonb not null default '{}'::jsonb,
created_by text not null references users(id),
updated_at timestamptz not null default now(),
created_at timestamptz not null default now()
);
create table document_revisions (
id bigserial primary key,
document_id text not null references documents(id),
actor_type text not null check (actor_type in ('user', 'api_key')),
actor_id text not null,
body jsonb not null,
created_at timestamptz not null default now()
);
create table comments (
id text primary key,
document_id text not null references documents(id),
author_id text not null references users(id),
body text not null,
created_at timestamptz not null default now()
);
create table api_keys (
id text primary key,
organization_id text not null references organizations(id),
name text not null,
token_hash text not null,
created_by text not null references users(id),
revoked_at timestamptz
);
create table audit_events (
id bigserial primary key,
actor_type text not null,
actor_id text not null,
action text not null,
resource_type text not null,
resource_id text not null,
allowed boolean not null,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
Two tables are worth calling out.
document_revisions records the content history. That is application state.
audit_events records permission-sensitive actions. That is accountability.
OpenFGA is still the permission engine, but your product should be able to answer later:
Who edited this document?
Was it a user or API key?
Which permission check allowed it?
Which request or workflow caused it?
API-Level Permission Map
Before coding middleware, write the API map.
| API route | Principal | OpenFGA check |
|---|---|---|
GET /api/documents/:id | user or API key | can_view on document:id |
PATCH /api/documents/:id | user or API key | can_edit on document:id |
POST /api/documents/:id/comments | user only | can_comment on document:id |
POST /api/documents/:id/share | user only | can_share on document:id |
DELETE /api/documents/:id | user only | can_delete on document:id |
GET /api/documents | user or API key | list_objects for visible documents, plus DB filter |
This map is boring in the best possible way. It turns authorization into an explicit contract instead of scattered route logic.
TypeScript API Middleware
Here is a minimal server-side implementation using an Express-style API. The same pattern maps to Next.js route handlers, Remix loaders/actions, Astro server endpoints, Fastify, Hono, or NestJS.
import crypto from "node:crypto";
import { OpenFgaClient } from "@openfga/sdk";
import type { Request, Response, NextFunction } from "express";
const fga = new OpenFgaClient({
apiUrl: process.env.FGA_API_URL!,
storeId: process.env.FGA_STORE_ID!,
authorizationModelId: process.env.FGA_MODEL_ID!,
});
type Principal =
| { type: "user"; id: string; fgaUser: `user:${string}` }
| { type: "api_key"; id: string; fgaUser: `api_key:${string}` };
declare global {
namespace Express {
interface Request {
principal?: Principal;
}
}
}
async function authenticate(req: Request, _res: Response, next: NextFunction) {
const apiKey = req.header("x-api-key");
if (apiKey) {
const key = await findApiKeyByHash(hashToken(apiKey));
if (!key || key.revoked_at) throw forbidden("Invalid API key");
req.principal = {
type: "api_key",
id: key.id,
fgaUser: `api_key:${key.id}`,
};
return next();
}
const session = await requireSession(req);
req.principal = {
type: "user",
id: session.user.id,
fgaUser: `user:${session.user.id}`,
};
return next();
}
function requirePrincipal(req: Request): Principal {
if (!req.principal) throw forbidden("Missing principal");
return req.principal;
}
async function assertDocumentAccess(
req: Request,
documentId: string,
relation: "can_view" | "can_comment" | "can_edit" | "can_share" | "can_delete",
) {
const principal = requirePrincipal(req);
const result = await fga.check({
user: principal.fgaUser,
relation,
object: `document:${documentId}`,
});
await insertAuditEvent({
actorType: principal.type,
actorId: principal.id,
action: relation,
resourceType: "document",
resourceId: documentId,
allowed: Boolean(result.allowed),
});
if (!result.allowed) throw forbidden("Forbidden");
}
function hashToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}
function forbidden(message: string) {
return Object.assign(new Error(message), { status: 403 });
}
This is the main app pattern:
authenticate once
represent principal as OpenFGA subject
map route to relation
check OpenFGA before touching protected data
write audit event
execute business logic
Document APIs
Now the route handlers become clear.
app.get("/api/documents/:id", authenticate, async (req, res) => {
const documentId = req.params.id;
await assertDocumentAccess(req, documentId, "can_view");
const document = await db.document.findUniqueOrThrow({
where: { id: documentId },
});
res.json(document);
});
app.patch("/api/documents/:id", authenticate, async (req, res) => {
const documentId = req.params.id;
await assertDocumentAccess(req, documentId, "can_edit");
const updated = await db.$transaction(async (tx) => {
const document = await tx.document.update({
where: { id: documentId },
data: {
title: req.body.title,
body: req.body.body,
updated_at: new Date(),
},
});
await tx.documentRevision.create({
data: {
document_id: documentId,
actor_type: req.principal!.type,
actor_id: req.principal!.id,
body: req.body.body,
},
});
return document;
});
res.json(updated);
});
app.post("/api/documents/:id/comments", authenticate, async (req, res) => {
if (req.principal?.type !== "user") {
throw forbidden("API keys cannot comment");
}
const documentId = req.params.id;
await assertDocumentAccess(req, documentId, "can_comment");
const comment = await db.comment.create({
data: {
id: crypto.randomUUID(),
document_id: documentId,
author_id: req.principal.id,
body: req.body.body,
},
});
res.status(201).json(comment);
});
The API key rule is intentional. Even if an API key somehow had can_comment, the route rejects it because product policy says comments are human-authored.
That is normal: OpenFGA answers relationship authorization, while application code still enforces product-specific action shape.
Sharing API
Sharing is where relationship writes happen.
Only humans with can_share should create relationship tuples for other people or API keys.
app.post("/api/documents/:id/share", authenticate, async (req, res) => {
if (req.principal?.type !== "user") {
throw forbidden("API keys cannot share documents");
}
const documentId = req.params.id;
await assertDocumentAccess(req, documentId, "can_share");
const { targetType, targetId, role } = req.body as {
targetType: "user" | "api_key";
targetId: string;
role: "viewer" | "commenter" | "editor";
};
if (targetType === "api_key" && role === "commenter") {
throw forbidden("API keys cannot receive commenter access");
}
await fga.write({
writes: [
{
user: `${targetType}:${targetId}`,
relation: role,
object: `document:${documentId}`,
},
],
});
await insertAuditEvent({
actorType: req.principal.type,
actorId: req.principal.id,
action: `share:${role}`,
resourceType: "document",
resourceId: documentId,
allowed: true,
metadata: { targetType, targetId },
});
res.status(204).send();
});
This is a good place to add extra guardrails:
- only allow sharing inside the same organization
- require confirmation before sharing with API keys
- limit API key editor access to integration-owned documents
- notify owners when a document is shared externally
- write an audit event for every tuple change
Listing Documents
Document listing is where teams often get stuck.
For small tenants, you can ask OpenFGA which documents a principal can view, then fetch those documents from Postgres.
app.get("/api/documents", authenticate, async (req, res) => {
const principal = requirePrincipal(req);
const visible = await fga.listObjects({
user: principal.fgaUser,
relation: "can_view",
type: "document",
});
const documentIds = visible.objects.map((object) =>
object.replace("document:", ""),
);
const documents = await db.document.findMany({
where: { id: { in: documentIds } },
orderBy: { updated_at: "desc" },
take: 50,
});
res.json(documents);
});
For large tenants, avoid turning every page load into a huge authorization query. Common options are:
- query by organization first, then batch-check candidates
- maintain a search index with document IDs and tenant IDs
- denormalize selected permissions for fast lists while keeping OpenFGA as the source of truth
- cache low-risk checks for short periods
- use background jobs to update indexes after tuple changes
The security rule remains the same: never return a document unless the current principal can view it.
UI Permission Checks
The UI should not guess. It should ask the API for capabilities.
app.get("/api/documents/:id/capabilities", authenticate, async (req, res) => {
const principal = requirePrincipal(req);
const documentId = req.params.id;
const checks = await Promise.all(
["can_view", "can_comment", "can_edit", "can_share", "can_delete"].map(
async (relation) => {
const result = await fga.check({
user: principal.fgaUser,
relation,
object: `document:${documentId}`,
});
return [relation, Boolean(result.allowed)] as const;
},
),
);
res.json(Object.fromEntries(checks));
});
The frontend can render:
export function DocumentToolbar({ capabilities }: { capabilities: Capabilities }) {
return (
<div className="toolbar">
<button disabled={!capabilities.can_edit}>Save</button>
<button disabled={!capabilities.can_comment}>Comment</button>
<button disabled={!capabilities.can_share}>Share</button>
<button disabled={!capabilities.can_delete}>Delete</button>
</div>
);
}
This improves UX, but the API checks are still mandatory. A disabled button is not security.
Deployment Shape
For a real deployment, keep the layers separate:
CDN / edge
|
v
Web app container
|
| sessions/JWTs
v
Auth provider
Web app container
|
| SQL
v
Postgres
Web app container
|
| authorization checks + tuple writes
v
OpenFGA
|
| stores OpenFGA state
v
OpenFGA datastore
Worker container
|
| background indexing, exports, notifications
v
Postgres + OpenFGA + object storage
A local Docker Compose setup can look like this:
services:
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:app@postgres:5432/docs
FGA_API_URL: http://openfga:8080
FGA_STORE_ID: ${FGA_STORE_ID}
FGA_MODEL_ID: ${FGA_MODEL_ID}
depends_on:
- postgres
- openfga
postgres:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: docs
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d docs"]
interval: 5s
timeout: 5s
retries: 10
openfga-migrate:
image: openfga/openfga:latest
command: migrate
environment:
OPENFGA_DATASTORE_ENGINE: postgres
OPENFGA_DATASTORE_URI: postgres://app:app@postgres:5432/docs?sslmode=disable
depends_on:
postgres:
condition: service_healthy
openfga:
image: openfga/openfga:latest
command: run
environment:
OPENFGA_DATASTORE_ENGINE: postgres
OPENFGA_DATASTORE_URI: postgres://app:app@postgres:5432/docs?sslmode=disable
ports:
- "8080:8080"
- "8081:8081"
depends_on:
postgres:
condition: service_healthy
openfga-migrate:
condition: service_completed_successfully
volumes:
postgres-data:
In production, I would usually separate the app database and OpenFGA datastore, even if local development shares Postgres for convenience. That makes backup, scaling, migration, and access control cleaner.
Deployment Checklist for the Mini Docs App
To deploy this safely, you need more than containers.
| Layer | Production requirement |
|---|---|
| Auth | verified sessions or JWTs, stable user IDs, organization membership sync |
| App API | route-to-permission map, authorization middleware, structured errors |
| Postgres | migrations, backups, tenant filters, optional RLS, revision history |
| OpenFGA | model migration process, tuple writer service, health checks |
| Secrets | API key hashing, secret rotation, no plaintext tokens in logs |
| Audit | tuple writes, denied checks, document writes, shares, deletes |
| UI | capability endpoint, disabled actions, clear sharing states |
| Workers | same authorization helpers as API routes, no privileged shortcuts |
| Observability | latency for checks, denied rate, tuple write failures, audit coverage |
The simple version can be coded in a weekend. The production version needs discipline around the places where identity, permissions, and data meet.
The pattern is worth it because the same design grows naturally:
documents today
folders tomorrow
workspaces next month
MCP tools after that
workflow agents later
You do not need to throw the model away when the product gets more collaborative.
The Product Modeling Questions
The hardest part of OpenFGA is not the SDK. It is the model.
Ask these questions before writing code:
- What are the objects users act on?
- Which relationships are durable facts?
- Which permissions are derived from other relationships?
- Which permissions are global, tenant-level, project-level, or resource-level?
- Can permissions be delegated to teams, groups, service accounts, or agents?
- Do any decisions require request context such as time, IP, environment, risk, or approval state?
- Which checks must be low latency enough for page rendering?
- Which checks must be auditable because they touch money, data export, production, or customer impact?
This usually produces better models than starting with roles.
Roles are still useful, but roles should describe relationships to objects:
user:luis is owner of organization:acme
user:maya is editor of project:agent-platform
team:support is viewer of workspace:acme
agent:deploy-bot is operator of environment:staging
That is more flexible than:
user:luis has role ADMIN
Because the obvious follow-up is: admin of what?
Where OpenFGA Ends
OpenFGA is not your whole security system.
It does not replace:
- login and session management
- OAuth authorization flows
- token validation
- database grants and row-level security
- input validation
- output filtering
- rate limits
- audit logs
- human approval for risky operations
- security reviews of tools and agents
Think of OpenFGA as the authorization decision engine. It needs to be called by other systems that still do their own jobs.
OAuth / Authn proves identity
OpenFGA decides relationship permission
Database/RLS enforces data boundary
Tool gateway limits action shape
Audit logs preserve accountability
Approval workflow handles high-risk action consent
This separation becomes very important for MCP and AI agents.
OpenFGA for MCP Servers
MCP servers expose tools, resources, and prompts to AI clients. That makes authorization more concrete than a normal web page.
You need to answer questions like:
Can this user list Jira tickets for this project?
Can this user invoke the refund tool for this customer?
Can this agent read this repository through MCP?
Can this tool operate in production, or only staging?
MCP’s authorization specification focuses on OAuth flows, protected resource metadata, scopes, resource indicators, token validation, and 401 or 403 challenges. That is necessary, but scopes are usually too coarse for product authorization.
A scope like this:
tools:call
does not tell you whether the user can call refund_customer for customer:acme.
OpenFGA can add the missing resource-level decision.
MCP client
|
| Authorization: Bearer <token>
v
MCP server
|
| validate token, audience, issuer, scopes
v
Tool router
|
| OpenFGA check:
| user:ana can_call tool:refund_customer
| user:ana can_refund customer:acme
v
Tool handler
A small model for MCP tools:
model
schema 1.1
type user
type workspace
relations
define admin: [user]
define member: [user]
type customer
relations
define parent: [workspace]
define support_agent: [user] or admin from parent
define billing_manager: [user] or admin from parent
define can_view: support_agent or billing_manager
define can_refund: billing_manager
type tool
relations
define parent: [workspace]
define allowed_user: [user]
define allowed_member: member from parent
define can_call: allowed_user or allowed_member
Then the MCP tool handler checks both the tool and the target resource:
async function refundCustomerTool(ctx: McpContext, input: { customerId: string }) {
const user = await requireMcpUser(ctx);
await assertAllowed({
userId: user.id,
relation: "can_call",
object: "tool:refund_customer",
});
await assertAllowed({
userId: user.id,
relation: "can_refund",
object: `customer:${input.customerId}`,
});
return createRefundDraft(input.customerId);
}
Notice the last line: createRefundDraft, not issueRefundImmediately.
For sensitive actions, authorization should not automatically mean autonomous execution. It may mean “this user can ask the agent to prepare the action, then a human approves it.”
OpenFGA for RAG and Agent Memory
RAG systems often fail authorization by retrieving first and filtering later.
That is risky. If the model sees unauthorized context, the damage has already happened.
A safer RAG flow is:
User question
|
v
Resolve user identity and tenant
|
v
Find candidate documents
|
v
Filter by OpenFGA permissions
|
v
Fetch allowed chunks only
|
v
Generate answer
In practice, you can combine search indexes and OpenFGA in a few ways.
For small candidate sets, retrieve candidates and run batch checks:
async function authorizedSearch(userId: string, query: string) {
const candidates = await vectorSearch(query, { limit: 40 });
const checks = await Promise.all(
candidates.map(async (doc) => {
const result = await fga.check({
user: `user:${userId}`,
relation: "can_view",
object: `document:${doc.documentId}`,
});
return result.allowed ? doc : null;
}),
);
return checks.filter(Boolean);
}
For large systems, use OpenFGA to constrain the search space before retrieval where possible:
List objects user can view
|
v
Use allowed document IDs as search filter
|
v
Retrieve chunks only from allowed documents
The exact implementation depends on your search engine, tenant size, latency budget, and index design. The principle is stable:
The model should only receive context the user is authorized to see.
This also applies to agent memory. Memory is not just “data.” It is remembered data with unclear future use. If memories can contain customer facts, secrets, strategy, private conversations, or tenant data, they need authorization boundaries too.
OpenFGA for Workflow Agents
Workflow agents introduce a subtle authorization problem:
The person who requested the work is not always the same identity as the process performing the work.
Human user
|
| "Deploy the staging build"
v
Workflow orchestrator
|
v
Agent worker
|
v
GitHub / CI / Cloud / Database
You need to preserve at least four identities:
| Identity | Question |
|---|---|
| Human user | Who requested this? |
| Agent | Which agent is acting? |
| Task | What specific job was delegated? |
| Resource | What is being touched? |
A useful model is to represent tasks as objects.
model
schema 1.1
type user
type agent
relations
define operator: [user]
type environment
relations
define deployer: [user, agent]
define approver: [user]
define can_deploy: deployer
define can_approve: approver
type workflow_task
relations
define requester: [user]
define assignee: [agent]
define target_environment: [environment]
define can_execute: assignee
define can_approve: can_approve from target_environment
Then an orchestrator can check:
async function executeDeploymentTask(task: WorkflowTask) {
await assertSubjectAllowed({
subject: `agent:${task.agentId}`,
relation: "can_execute",
object: `workflow_task:${task.id}`,
});
await assertSubjectAllowed({
subject: `agent:${task.agentId}`,
relation: "can_deploy",
object: `environment:${task.environmentId}`,
});
if (task.environment === "production") {
await requireHumanApproval(task.id);
}
return startDeployment(task);
}
This makes the agent’s authority explicit.
The agent is not powerful because it has a secret in an environment variable. It is allowed because the authorization model says this specific agent can execute this specific class of task against this specific resource.
Task-Scoped Delegation
Task-scoped delegation is the agent pattern I expect to matter more over time.
Instead of granting an agent broad standing access:
agent:researcher can_read all_documents in workspace:acme
Grant it narrow temporary authority:
agent:researcher can_execute workflow_task:task-789
workflow_task:task-789 can_read document:pricing-notes
workflow_task:task-789 expires at 2026-05-08T18:00:00Z
The shape is:
Human grants task
|
v
Task gets limited resource relations
|
v
Agent acts through task
|
v
Task expires or is revoked
OpenFGA’s relationship model is a good fit for the durable part of this pattern. If you need time, risk, IP address, approval status, or other dynamic facts, use contextual conditions or combine OpenFGA checks with your own policy layer.
For example:
await fga.check({
user: `agent:${agentId}`,
relation: "can_execute",
object: `workflow_task:${taskId}`,
contextualTuples: [
{
user: `workflow_task:${taskId}`,
relation: "can_read",
object: `document:${documentId}`,
},
],
});
Contextual tuples are useful when a relationship exists only for this request or can be derived from the current workflow state. Be careful not to hide durable product truth inside ephemeral request code. If a relation must be auditable and reusable, store it.
OpenFGA and Databases
OpenFGA decides whether an action should be allowed. Your database should still enforce what data can be touched.
For example, a document edit flow can combine both:
API route
|
| OpenFGA: user can_edit document
v
Database query
|
| tenant_id filter / RLS / narrow DB role
v
Mutation
Do not give an AI agent a database owner credential and hope OpenFGA checks happen everywhere. The database should use narrow roles, row-level security, views, stored procedures, statement timeouts, and audit logs where appropriate.
For sensitive systems, treat OpenFGA and the database as two independent locks.
OpenFGA says: this subject may attempt the action.
Database says: this query may only touch these rows and columns.
That is defense in depth, and it is especially important when agents can be influenced by prompts, retrieved documents, tool outputs, or ambiguous human requests.
Common Modeling Mistakes
Here are mistakes I would watch for in the first implementation.
Mistake 1: Modeling Every Verb as a Stored Relation
Do not store everything as tuples.
Prefer:
define can_edit: editor or owner
Over storing:
document:roadmap#can_edit@user:luis
document:roadmap#can_view@user:luis
document:roadmap#can_comment@user:luis
Store stable facts. Derive permissions.
Mistake 2: Forgetting the Object Boundary
“Admin” is ambiguous.
Admin of what?
organization admin
workspace admin
project admin
billing admin
environment admin
tool admin
Global roles are easy to create and hard to safely remove.
Mistake 3: Using UI Checks as Security
Hiding a button is not authorization.
Every mutation and sensitive read needs a server-side check. Agent tools need the same discipline.
Mistake 4: Giving Agents Human-Sized Permissions
An agent acting for Luis should not automatically get every permission Luis has.
Give the agent the smallest permission set needed for the task. For high-risk operations, require approval even when the user could perform the action manually.
Mistake 5: Skipping Model Tests
Authorization bugs are product bugs and security bugs at the same time.
Write tests for your model:
Given Luis is org admin
When checking can_edit on a document in that org
Then allowed is true
Given guest-42 is direct_viewer
When checking can_edit on the document
Then allowed is false
Given triage-agent is assigned to task-789
When checking can_read on unrelated customer invoice
Then allowed is false
These tests become your executable permission spec.
Production Checklist
Before using OpenFGA in production, I would want this checklist covered.
| Area | What to decide |
|---|---|
| Model lifecycle | How models are reviewed, versioned, migrated, and rolled back |
| Tuple writes | Which service is allowed to create or delete relationships |
| Latency | Which routes need caching, batching, or denormalized permission indexes |
| Consistency | Which actions require read-after-write correctness |
| Audit | Which checks, tuple changes, tool calls, and approvals are logged |
| Testing | Which user journeys and deny cases are covered |
| Admin tooling | How support teams inspect “why does this user have access?” |
| Break-glass | How emergency access is granted, limited, logged, and revoked |
| Agent delegation | Whether agents get standing permissions or task-scoped permissions |
| Human approval | Which actions can be prepared by agents but not executed autonomously |
The product-facing feature is “permissions.” The engineering system is model governance, tuple lifecycle, checks, observability, and operational discipline.
A Practical Adoption Path
You do not need to model the whole company on day one.
Start with one high-value boundary.
Good first projects:
- document sharing
- project membership
- customer support access
- internal admin actions
- MCP tool access
- RAG document filtering
- agent task delegation
An adoption path can look like this:
Step 1: Model one resource type
Step 2: Add server-side checks to one API surface
Step 3: Write allow and deny tests
Step 4: Add batch checks for UI controls
Step 5: Add audit logs for sensitive decisions
Step 6: Extend model to teams, projects, and agents
Step 7: Add operational tooling for support and debugging
The goal is not to make authorization fancy. The goal is to make it explicit, testable, and reusable.
The Expert Move: Separate Authority from Execution
The most important design idea is this:
Authorization says whether a subject may attempt an action. Execution still needs risk controls.
For a normal web app, that means a user who can edit a document should still pass validation, rate limits, tenant checks, and database constraints.
For an MCP server, that means a user who can call a tool should still be limited to safe tool arguments and approved resources.
For an agent workflow, that means an agent that can prepare a refund, deployment, data export, or permission change may still need human approval before execution.
This distinction keeps your system sane.
Permission check Can this subject attempt this?
Input validation Is the request shaped correctly?
Risk policy Is this action sensitive?
Approval workflow Does a human need to confirm?
Execution layer Can the downstream system enforce limits?
Audit log Can we explain what happened later?
OpenFGA is powerful because it gives you a consistent way to answer the first question across all those surfaces.
Final Mental Model
If you remember one thing, make it this:
Authentication identifies the actor.
OpenFGA evaluates the actor's relationship to the resource.
Your app executes only the allowed action.
Your database and tools enforce the blast radius.
Your audit trail explains the decision later.
That is useful for classic SaaS.
It is even more useful for agents.
Because once software can plan, call tools, retrieve private context, and act across systems, permissions need to become a first-class product primitive rather than scattered if statements.
OpenFGA will not solve every security problem in modern apps, MCP servers, workflow agents, or orchestrators. But it gives you a clean authorization backbone: explicit relationships, testable decisions, and a vocabulary that can grow from “can edit this document” to “can this agent execute this task against this customer through this tool right now?”
That is the jump from roles to real authorization.
Source List
- OpenFGA documentation: What is Fine-Grained Authorization?
- OpenFGA documentation: Modeling overview
- OpenFGA documentation: Direct access, groups, roles, and relationships
- OpenFGA documentation: Relationship queries, checks, and list objects
- OpenFGA documentation: Modeling agents as principals
- OpenFGA documentation: Authorization for MCP servers
- OpenFGA documentation: Task-based authorization for agents
- OpenFGA documentation: RAG authorization
- OpenFGA documentation: Token claims as contextual tuples
- OpenFGA documentation: Conditions
- OpenFGA SDKs and integration documentation
- OpenFGA Docker setup guide
- Zanzibar: Google’s Consistent, Global Authorization System
- Model Context Protocol authorization specification
- OWASP Agentic AI Threats and Mitigations
- LangChain security policy
- Amazon Bedrock Agents security best practices