AgentFGA is a fine-grained authorization server for AI agents and agentic products. It decides what each agent, and each user it acts for, can see and do, down to a single document, tool or record.
You describe your rules once, as an authorization model. You store relationships as they change, such as “the support bot may use the refund tool”. Then your agent asks AgentFGA before it acts. AgentFGA speaks the OpenFGA HTTP API, so the official OpenFGA SDKs, the fga CLI and the modeling tools work against it unchanged.
Check every action. Check and BatchCheck answer whether an agent may use a tool or touch a record, fast enough to ask before every call.
Filter what the model sees. ListObjects and BatchCheck keep retrieval to what the user may read.
Access that expires. Conditions limit a grant by time, IP range or any other request detail. Contextual tuples grant access for a single request.
One store per customer. Each customer's model and relationships live apart from everyone else's, and capacity grows as you add stores.
Compatible with OpenFGA. Every response, including error codes and messages, was compared with OpenFGA v1.22.0 across more than 1.7 million side-by-side requests.
Run AgentFGA on your machine, give an agent permission to use a tool, and check it.
You need Node.js 22.13 or later and a copy of the AgentFGA repository. The examples use the fga CLI and the OpenFGA JavaScript SDK; any OpenFGA SDK works.
Start AgentFGA
From the repository, install dependencies, choose an API key and start the local server. The key in .dev.vars is the token your agents and apps will send.
shell
npm install
echo "AGENTFGA_AUTHN_PRESHARED_KEYS=dev-key-123" > .dev.vars
npm run dev # http://127.0.0.1:8787
Create a store
A store holds one customer's model and relationships. Point your tools at the server, create a store, and keep its id in FGA_STORE_ID.
export FGA_API_URL=http://127.0.0.1:8787
export FGA_API_TOKEN=dev-key-123
fga store create --name acme
export FGA_STORE_ID=01JBZ8M2X3K4Q5R6S7T8V9W0XY # the id it printed
export FGA_API_URL=http://127.0.0.1:8787
export FGA_API_TOKEN=dev-key-123
curl -X POST $FGA_API_URL/stores \
-H "Authorization: Bearer $FGA_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "acme"}'
export FGA_STORE_ID=01JBZ8M2X3K4Q5R6S7T8V9W0XY # the id it returned
import { CredentialsMethod, OpenFgaClient } from "@openfga/sdk";
const credentials = {
method: CredentialsMethod.ApiToken,
config: { token: process.env.FGA_API_TOKEN! },
} as const;
const admin = new OpenFgaClient({ apiUrl: process.env.FGA_API_URL!, credentials });
const store = await admin.createStore({ name: "acme" });
// Every later call goes to this store.
const fga = new OpenFgaClient({ apiUrl: process.env.FGA_API_URL!, storeId: store.id, credentials });
Write your rules
Save this model as model.fga. Agents can use tools they are granted directly or through a team, and can view a document when someone grants them temporary access.
model.fga
model
schema 1.1
type user
type agent
type team
relations
define member: [user, agent]
type tool
relations
define can_use: [agent, team#member]
type document
relations
define owner: [user]
define temp: [agent with not_expired]
define viewer: [user, team#member] or owner or temp
condition not_expired(current_time: timestamp, grant_time: timestamp, grant_duration: duration) {
current_time < grant_time + grant_duration
}
fga model write --file model.fga
import { readFileSync } from "node:fs";
import { transformer } from "@openfga/syntax-transformer";
const model = transformer.transformDSLToJSONObject(readFileSync("model.fga", "utf8"));
const { authorization_model_id } = await fga.writeAuthorizationModel(model);
Grant access
Store who is related to what. Here the support bot may use the refund tool, and Anne owns the Q3 plan.
An agent acts for a user, calls many tools and reads a lot of data, faster than anyone can review. These patterns keep each agent to what it, and the person it works for, may do.
The examples use the model from the Quickstart and an OpenFGA SDK client called fga.
Give each agent an identity
Model agents as their own type, such as agent:support-bot, instead of letting them borrow a user's identity. Grants then say exactly what each agent may do, and they show up separately in Read and ReadChanges. For short-lived agents, give each run its own id, such as agent:run-7f3, and grant access through a team or with contextual tuples. For agents your customers create and manage themselves, with managers, an off switch and grants that expire, see Agent service accounts.
Check before every tool call
Put one check in front of the code that runs tools, so no tool runs without a yes. A check takes about 6 ms on the server, so it can sit on every call.
tools.ts
async function runTool(agentId: string, tool: Tool, input: unknown) {
const { allowed } = await fga.check({
user: `agent:${agentId}`,
relation: "can_use",
object: `tool:${tool.name}`,
});
if (!allowed) throw new Error(`agent:${agentId} may not use ${tool.name}`);
return tool.run(input);
}
Filter what the model sees
Before retrieved content reaches the model, keep only what the user may read. There are two ways to do it.
Filter first, with ListObjects, when a user can see a modest number of documents. Ask for every document the user may view, then search only those. ListObjects returns at most 1,000 results within 3 seconds by default.
Search first, then check, with BatchCheck, when the corpus is large. Retrieve candidates as usual, then check them all in one request. The SDK splits the list into batches of 50.
When an agent works for someone, the action should pass two tests: the agent may use the tool, and the user may touch the thing. Ask both in one BatchCheck and proceed only if both are allowed. Agent service accounts extends this to report which side refused.
The temp relation in the model only accepts grants with the not_expired condition. Store when the grant starts and how long it lasts with the tuple, then send the current time with each check. Once the hour is up, the same check answers false, with nothing to clean up.
TypeScript
// Grant the support bot one hour with the Q3 plan.
await fga.write({
writes: [{
user: "agent:support-bot",
relation: "temp",
object: "document:q3-plan",
condition: {
name: "not_expired",
context: { grant_time: new Date().toISOString(), grant_duration: "1h" },
},
}],
});
// Later, pass the current time with the check.
const { allowed } = await fga.check({
user: "agent:support-bot",
relation: "viewer",
object: "document:q3-plan",
context: { current_time: new Date().toISOString() },
});
Grants for a single run
Contextual tuples are relationships you send with a query instead of storing. They count for that one request and are then gone, which suits access tied to one agent run or session. Here a run joins the support team for one check, and so may use the tools the team may use.
If you build an agentic product for many customers, create a store for each. Their models and relationships stay apart, a mistake in one customer's grants can't leak into another's, and on Cloudflare each store runs on its own, so capacity grows as you add customers. See Stores and scaling.
Examples
Custom roles
Your app fixes the permissions. Each organization builds its own roles from them, such as “Billing manager”, and gives them to people, agents and teams. A role is tuples, so creating or changing one never needs a new model.
The complete example is in examples/custom-roles in the AgentFGA repository: the model, tests for it, the TypeScript an app calls, and a demo that runs against a server. It uses only the OpenFGA API, so it runs on OpenFGA too. See Run it.
The model
Each permission is a relation on the organization that accepts the members of a role. Owners hold every permission, so no organization can lock itself out.
model.fga
model
schema 1.1
type user
type agent
type team
relations
define member: [user, agent]
type role
relations
define organization: [organization]
define assignee: [user, agent, team#member, role#assignee]
define member: assignee and member from organization
type organization
relations
define owner: [user]
define member: [user, agent] or owner
define can_manage_roles: [role#member] or owner
define can_manage_members: [role#member] or owner
define can_manage_billing: [role#member] or owner
define can_view_billing: [role#member] or can_manage_billing
define can_create_projects: [role#member] or owner
define can_delete_projects: [role#member] or owner
define can_view_audit_log: [role#member] or owner
define can_export_data: [role#member] or owner
type project
relations
define organization: [organization]
define creator: [user, agent]
define can_delete: creator or can_delete_projects from organization
A role is an object. Its assignees are users, agents, teams, or the assignees of another role, so one role can include another.
Roles end when people leave. A role's member is its assignees who still belong to the organization, and permissions go to role:<id>#member. Remove someone from the organization and every role they held stops applying, with no assignments to clean up.
Permissions can imply each other. Whoever manages billing can view it, whatever role they hold.
Permissions flow down. A role that may delete projects may delete every project in the organization.
Create and assign a role
A role is a link to its organization and one tuple per permission. Each assignment is one more tuple. Keep the role's name and description in your own database; the tuples hold only its id.
# Create "Billing manager" with two permissions
fga tuple write organization:acme organization role:billing-manager
fga tuple write role:billing-manager#member can_manage_billing organization:acme
fga tuple write role:billing-manager#member can_export_data organization:acme
# Give it to the finance team and to an agent
fga tuple write team:finance#member assignee role:billing-manager
fga tuple write agent:invoice-bot assignee role:billing-manager
# Make "Finance lead" include it
fga tuple write role:finance-lead#assignee assignee role:billing-manager
await fga.write({
writes: [
// Create "Billing manager" with two permissions
{ user: "organization:acme", relation: "organization", object: "role:billing-manager" },
{ user: "role:billing-manager#member", relation: "can_manage_billing", object: "organization:acme" },
{ user: "role:billing-manager#member", relation: "can_export_data", object: "organization:acme" },
// Give it to the finance team and to an agent
{ user: "team:finance#member", relation: "assignee", object: "role:billing-manager" },
{ user: "agent:invoice-bot", relation: "assignee", object: "role:billing-manager" },
// Make "Finance lead" include it
{ user: "role:finance-lead#assignee", relation: "assignee", object: "role:billing-manager" },
],
});
Assignees must be members of the organization for their roles to apply, agents included: agent:invoice-bot member organization:acme.
Check a permission
Your app checks permissions, never roles, so organizations can reshape their roles without any change to your code.
fga query check user:anne can_manage_billing organization:acme
# Everything Anne may do in the organization
fga query list-relations user:anne organization:acme
# Everyone who may manage billing, through any role
fga query list-users --object organization:acme --relation can_manage_billing \
--user-filter user
const { allowed } = await fga.check({
user: "user:anne",
relation: "can_manage_billing",
object: "organization:acme",
});
// Everything Anne may do in the organization
const { relations } = await fga.listRelations({
user: "user:anne",
object: "organization:acme",
relations: ["can_manage_roles", "can_manage_billing", "can_view_billing", "can_export_data"],
});
// Everyone who may manage billing, through any role
const { users } = await fga.listUsers({
object: { type: "organization", id: "acme" },
relation: "can_manage_billing",
user_filters: [{ type: "user" }],
});
Hand out only what you hold
Someone who may manage roles shouldn't be able to create a role with more than they have, or give one like that to themselves. Before each change, check in one BatchCheck that the person making it holds can_manage_roles and every permission the change hands out.
TypeScript
async function requireAll(actor: string, object: string, relations: string[]) {
const { result } = await fga.batchCheck({
checks: relations.map((relation) => ({
user: actor,
relation,
object,
correlationId: relation,
})),
});
const missing = result.filter((r) => !r.allowed).map((r) => r.correlationId);
if (missing.length) {
throw new Error(`${actor} lacks ${missing.join(", ")} on ${object}`);
}
}
// Carla may manage roles, but doesn't hold can_export_data: refused.
await requireAll("user:carla", "organization:acme", ["can_manage_roles", "can_export_data"]);
To assign a role, check the permissions it carries, read with fga.read({ user: "role:billing-manager#member", object: "organization:acme" }), plus those of every role it includes. To remove a permission from a role, require that permission too: you can't take away what you couldn't give.
Change or delete a role
Adding or removing a permission is one tuple, and every holder has the change on the next check. To delete a role, read its tuples and delete them in one write:
those on the role: fga.read({ object: "role:billing-manager" }) returns its organization link, its assignees and the roles that include it;
its permissions: fga.read({ user: "role:billing-manager#member", object: "organization:acme" });
the roles it includes: fga.read({ user: "role:billing-manager#assignee", relation: "assignee", object: "role:" }).
Use this pattern when a role means the same thing across the organization, like the roles in an admin console. When the same person needs different roles on different projects, see Custom roles per project. An app can use both.
Run it
From the AgentFGA repository. The demo creates a new store, walks through creating, assigning, changing and deleting roles, and asserts every answer.
shell
# The model's tests, on AgentFGA's own engine: no server needed
npx tsx src/server/main.ts model test --tests examples/custom-roles/store.fga.yaml
# A local server, then the demo in another terminal
npm run server -- --authn-method preshared --authn-preshared-keys dev-key-123
FGA_API_URL=http://127.0.0.1:8080 FGA_API_TOKEN=dev-key-123 npx tsx examples/custom-roles/demo.ts
Examples
Custom roles per project
Define roles once and assign them on each project: Anne is release manager on the API and a developer on the website. An assignment on the organization covers every project, and a change to a role applies everywhere it is assigned.
The complete example is in examples/custom-roles-per-project in the AgentFGA repository, with tests and a demo. See Run it.
The model
A role assignment ties one role to its assignees on one project, or on the organization. Projects collect their own assignments and the organization's.
model.fga
model
schema 1.1
type user
type agent
type team
relations
define member: [user, agent]
type organization
relations
define owner: [user]
define member: [user, agent] or owner
define can_manage_roles: owner
define assignment: [role_assignment]
define can_view: can_view from assignment or owner
define can_edit: can_edit from assignment or owner
define can_deploy: can_deploy from assignment or owner
define can_manage_access: can_manage_access from assignment or owner
define can_delete: can_delete from assignment or owner
type role
relations
define organization: [organization]
define can_view: [user:*, agent:*]
define can_edit: [user:*, agent:*]
define can_deploy: [user:*, agent:*]
define can_manage_access: [user:*, agent:*]
define can_delete: [user:*, agent:*]
type role_assignment
relations
define role: [role]
define assignee: [user, agent, team#member]
define can_view: assignee and can_view from role
define can_edit: assignee and can_edit from role
define can_deploy: assignee and can_deploy from role
define can_manage_access: assignee and can_manage_access from role
define can_delete: assignee and can_delete from role
type project
relations
define organization: [organization]
define assignment: [role_assignment]
define can_view: can_view from assignment or can_view from organization or can_edit
define can_edit: can_edit from assignment or can_edit from organization
define can_deploy: can_deploy from assignment or can_deploy from organization
define can_manage_access: can_manage_access from assignment or can_manage_access from organization
define can_delete: can_delete from assignment or can_delete from organization
A role carries a permission through a wildcard.user:* can_deploy role:release-manager says any assignee of the role may deploy. A wildcard names one type, so with agents as well as people, write one tuple per type.
An assignee holds what the role carries, through assignee and can_deploy from role, and only where the assignment is attached.
The organization's permissions mean every project. Owners hold them all, and on a project can_view includes can_edit, so a role that edits can always read.
Define a role
A role is a link to its organization and two wildcard tuples per permission: one for people, one for agents.
Use one assignment object per role on each project, such as role_assignment:project.api.release-manager. The first assignee creates it with three tuples; each later assignee adds one. To assign on the whole organization, attach the assignment to organization:acme instead.
# Anne is release manager on the API
fga tuple write role:release-manager role role_assignment:project.api.release-manager
fga tuple write role_assignment:project.api.release-manager assignment project:api
fga tuple write user:anne assignee role_assignment:project.api.release-manager
# Engineering reads every project
fga tuple write role:reader role role_assignment:organization.acme.reader
fga tuple write role_assignment:organization.acme.reader assignment organization:acme
fga tuple write team:engineering#member assignee role_assignment:organization.acme.reader
import { ClientWriteRequestOnDuplicateWrites } from "@openfga/sdk";
// Anne is release manager on the API. The first two tuples exist after the
// first assignee, so a later assignment ignores duplicates.
const assignment = "role_assignment:project.api.release-manager";
await fga.write(
{
writes: [
{ user: "role:release-manager", relation: "role", object: assignment },
{ user: assignment, relation: "assignment", object: "project:api" },
{ user: "user:anne", relation: "assignee", object: assignment },
],
},
{ conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore } },
);
Check and list
fga query check user:anne can_deploy project:api # allowed
fga query check user:anne can_deploy project:web # denied
# The projects an agent may edit
fga query list-objects agent:coding-agent can_edit project
Defining roles takes can_manage_roles on the organization. Assigning one takes can_manage_access where it is assigned, and every permission the role carries there. A website admin can give out developer, but not release manager unless they can deploy the website themselves, and nothing on projects they don't administer. With requireAll from Custom roles:
From the AgentFGA repository. The demo defines four roles, assigns them per project and on the organization, shows a project admin being refused, and changes a role everywhere at once.
shell
# The model's tests, on AgentFGA's own engine: no server needed
npx tsx src/server/main.ts model test --tests examples/custom-roles-per-project/store.fga.yaml
# A local server, then the demo in another terminal
npm run server -- --authn-method preshared --authn-preshared-keys dev-key-123
FGA_API_URL=http://127.0.0.1:8080 FGA_API_TOKEN=dev-key-123 npx tsx examples/custom-roles-per-project/demo.ts
Examples
Agent service accounts
Give each AI agent its own identity, owned by the organization. Managers decide what it may do and can switch it off with one write. When a person runs the agent, it acts for them and can do only what both of them may do.
The complete example is in examples/agent-service-accounts in the AgentFGA repository, with tests and a demo. See Run it.
The model
model.fga
model
schema 1.1
type user
type team
relations
define member: [user]
type organization
relations
define admin: [user]
define member: [user] or admin
define can_create_service_accounts: admin
type service_account
relations
define organization: [organization]
define enabled: [service_account]
define manager: [user] or admin from organization
define runner: [user, team#member] or manager
define can_manage: manager
define can_run: runner
type tool
relations
define organization: [organization]
define can_use: [user, team#member, service_account#enabled, service_account#enabled with not_expired]
type project
relations
define organization: [organization]
define editor: [user, team#member, service_account#enabled, service_account#enabled with not_expired]
define viewer: [user, team#member, service_account#enabled, service_account#enabled with not_expired] or editor
condition not_expired(current_time: timestamp, grant_time: timestamp, grant_duration: duration) {
current_time < grant_time + grant_duration
}
The off switch is one tuple.enabled holds the account itself while it is on. Every grant names service_account:<id>#enabled, and the model rejects a grant to the bare account with validation_error, so no grant can bypass the switch.
Grants can expire. A grant written with not_expired stores when it starts and how long it lasts; each check sends the current time.
Managers and runners are people. Managers change what the account may do and switch it off; organization admins manage every account. Runners may start the agent as the account, but can't change it.
Create an account
Link it to the organization, switch it on, and name its managers and runners.
Grant to the account's #enabled set, for good or for a while. Let only managers grant, and only access they hold themselves: check can_manage on the account and the same relation on the object first.
Delete the enabled tuple. Every grant stops applying on the next check, including in runs already under way. The grants stay stored, so writing the tuple back restores them all.
# Off
fga tuple delete service_account:support-bot enabled service_account:support-bot
# Back on
fga tuple write service_account:support-bot enabled service_account:support-bot
Before a run starts, check that the account is on and that the person may run it. Then put one BatchCheck in front of every tool call and data access: the account's side and the person's side. Both must allow it, and the answer says which side refused.
A run with no person behind it, such as a scheduled job, checks the account alone.
Review what an agent can do
ListObjects answers what one account may do right now; ListUsers answers which accounts may do something. Send the current time with both, so expired grants drop out.
TypeScript
const context = { current_time: new Date().toISOString() };
// The tools support-bot may use
const { objects } = await fga.listObjects({
user: "service_account:support-bot",
relation: "can_use",
type: "tool",
context,
});
// The agents that may issue refunds
const { users } = await fga.listUsers({
object: { type: "tool", id: "issue-refund" },
relation: "can_use",
user_filters: [{ type: "service_account" }],
context,
});
Authentication stays in your app. Map the agent's credential, such as an API key or a workload token, to service_account:<id>, then ask AgentFGA what the account may do. Revoking a credential and switching the account off are separate controls; the switch also stops runs that already hold a valid credential.
Run it
From the AgentFGA repository. The demo creates two accounts, grants them access, runs one for two different people, switches it off mid-run and back on, and lets a 15-minute grant lapse.
shell
# The model's tests, on AgentFGA's own engine: no server needed
npx tsx src/server/main.ts model test --tests examples/agent-service-accounts/store.fga.yaml
# A local server, then the demo in another terminal
npm run server -- --authn-method preshared --authn-preshared-keys dev-key-123
FGA_API_URL=http://127.0.0.1:8080 FGA_API_TOKEN=dev-key-123 npx tsx examples/agent-service-accounts/demo.ts
Concepts
Authorization models
A model describes the types of things in your system and how people and agents can be related to them. AgentFGA uses OpenFGA's modeling language unchanged.
Types and relations
Each type is a kind of object, such as document or tool. Each define names a relation an object of that type can have. A relation either lists who can be related directly, in square brackets, or is computed from other relations.
model.fga
model
schema 1.1
type user
type folder
relations
define viewer: [user]
type document
relations
define parent: [folder]
define owner: [user]
define blocked: [user]
define viewer: ([user, user:*] or owner or viewer from parent) but not blocked
Who can be related directly
Written as
Allows tuples like
[user]
user:anne, one user
[team#member]
team:support#member, everyone with that relation to the team
[user:*]
user:*, every user, for public access
[agent with not_expired]
agent:support-bot, only with the condition attached
Combining relations
Operator
Meaning
Example
or
Either relation
define viewer: [user] or owner
and
Both relations
define can_publish: editor and approver
but not
The first, unless the second
define viewer: [user] but not blocked
from
A relation on a related object
define viewer: viewer from parent
Writing a model
The API takes models as JSON. Write them in the modeling language and convert them with the fga CLI or the @openfga/syntax-transformer package. AgentFGA validates a model exactly as OpenFGA does and rejects it with the same error if, for example, a relation refers to one that doesn't exist.
fga model write --store-id $FGA_STORE_ID --file model.fga
Models are never changed in place. Each write creates a new model with its own id, and queries use the newest model unless the request names one in authorization_model_id. Responses name the model that answered in the Openfga-Authorization-Model-Id header.
In production, pin the model id in your client. A new model then takes effect only when you deploy code that uses it.
Schema versions
AgentFGA accepts schema 1.1 and schema 1.2. Schema 1.2 is for modular models, split across files with module and extend type, and keeps their module metadata. A model can have up to 100 types by default.
Concepts
Relationship tuples
A tuple records one relationship: this user, or agent, has this relation to this object. Your model says what tuples mean; tuples say who actually has access.
Parts of a tuple
Field
Example
Notes
user
agent:support-bot
An object, a userset such as team:support#member, or a wildcard such as user:*
relation
can_use
A relation the object's type defines
object
tool:issue-refund
Written as type:id
condition
not_expired
Optional. A condition name and the parameters stored with it
Writing and deleting
One Write request can add and remove up to 100 tuples in total, and applies all of them or none. Writing a tuple that already exists, or deleting one that doesn't, is an error unless you set on_duplicate or on_missing to ignore.
Read returns stored tuples that match a filter, a page at a time. ReadChanges returns every write and delete in order, which suits audit logs and keeping another system in sync. Save the continuation token it returns and pass it next time to get only newer changes.
Contextual tuples
Check, BatchCheck, ListObjects, ListUsers and Expand accept up to 100 contextual tuples. They are treated as if they were stored, for that one request only. Use them for facts you already know at request time, such as the groups in a user's sign-in token or the agent run a request belongs to.
Every read in AgentFGA sees the latest writes, so a tuple you have just written applies to the very next check.
Concepts
Conditions
A condition makes a grant apply only when an expression is true, such as before a time or from an IP range. Conditions are written in CEL, as in OpenFGA.
Define a condition
Declare the condition in the model with typed parameters, then allow it on a relation with with.
model.fga
type document
relations
define editor: [user, user with ip_allowed]
condition ip_allowed(ip: ipaddress, cidr: string) {
ip.in_cidr(cidr)
}
Supply the parameters
Parameters can be stored with the tuple, sent with the request in context, or both. Usually the tuple holds what the grant allows and the request holds the facts of the moment.
bool, string, int, uint, double, duration (such as "1h"), timestamp (RFC 3339, such as "2026-10-27T09:00:00Z"), ipaddress, and list<T> and map<T> of these.
When a parameter is missing
If a condition needs a parameter that neither the tuple nor the request supplies, the request can fail with validation_error instead of answering. Always send the parameters your conditions read.
OpenFGA decides at random, per request, whether some of these cases fail or answer, so in rare cases the two servers can differ here. See OpenFGA compatibility.
How conditions are evaluated
AgentFGA evaluates CEL with cel-js, because OpenFGA's Go library can't run on Workers. All of OpenFGA's parameter types and condition tests behave the same. Error messages for a condition that doesn't compile are worded differently, and OpenFGA's limit on how much work a condition may do isn't enforced. The experimental inline $expression conditions are not supported.
Concepts
Stores and scaling
A store holds one set of models, tuples and assertions. Stores never see each other's data, which makes them the natural unit for customers and for capacity.
One store per customer
Create a store for each customer of your product and keep its id with the customer's record. Queries never cross stores, so one customer's grants can't affect another's.
How stores run on Cloudflare
On Workers, each store is its own Durable Object with its own SQLite database. The code that answers a check runs in the same process as the data, so each lookup during a check is a local read rather than a network round trip.
Your agent or appCalls the OpenFGA API with an SDK or plain HTTP.
WorkerChecks the API key, handles CORS and creates and lists stores.
One Durable Object per storeHolds the store's tuples, models, change history and assertions, and answers its queries.
Placement sets latency. A store lives near where it was created. A check takes about 6 ms when the request arrives in the same data center and about 20 ms from one region away, so create each customer's store from a server near where their agents run.
Size doesn't. Checks were as fast with 1.1 million relationships in a store as with 1,400. Each relationship takes about 361 bytes, including history and indexes.
Every read is up to date. The consistency option is accepted and changes nothing.
Capacity
One store answers about 500 to 700 single checks a second, or about 3,600 checks a second sent through BatchCheck. Past that, requests wait in line, then fail with 503 unavailable; retry them with backoff. Stores run independently, so total capacity grows with the number of stores: 16 stores served 6,799 requests a second with no errors.
The Node server has no per-store limit: any number of processes can serve one store from a shared database. See Node server and the benchmarks.
Run AgentFGA
Cloudflare Workers
Deploy AgentFGA to your own Cloudflare account. Each store runs in its own Durable Object, close to its data.
Requirements
Node.js 22.13 or later.
A Cloudflare account with Workers and Durable Objects. Wrangler, Cloudflare's CLI, is installed with the project's dependencies.
Run locally
shell
npm install
echo "AGENTFGA_AUTHN_PRESHARED_KEYS=dev-key-123" > .dev.vars
npm run dev # http://127.0.0.1:8787
Deploy
Save your API keys as a secret, separated by commas, then deploy. Wrangler prints the Worker's URL; use it as the SDK's apiUrl.
shell
npx wrangler secret put AGENTFGA_AUTHN_PRESHARED_KEYS
npm run deploy
Configure
Set options as vars in wrangler.jsonc, and secrets with wrangler secret put. Every option is listed in the configuration reference.
With AGENTFGA_DIAGNOSTIC_HEADERS set to true, responses carry X-Agentfga-Edge-Colo and X-Agentfga-Store-Colo, naming the data center that received the request and the one the store runs in. When the two differ, the request crossed regions; see placement.
Run AgentFGA
Node server
Run AgentFGA where you run OpenFGA today: on Postgres, MySQL, SQLite or memory. It takes the same flags, environment variables and defaults as openfga run, so an existing deployment's configuration starts it.
Build and start
npm run build:server produces dist/agentfga.mjs, one file with its database drivers and migrations inside. It needs Node.js 22.13 or later.
To try it from source without building: npm run server -- --datastore-engine sqlite --datastore-uri ./openfga.db. agentfga run --help lists every flag.
Datastores
Engine
--datastore-uri
memory
Not needed. The default: a private SQLite database that lasts as long as the process.
sqlite
A file path, or a file: URI with _pragma= parameters.
postgres
A URI such as postgres://user:pass@db:5432/openfga.
mysql
A URI, or a Go DSN such as user:pass@tcp(db:3306)/openfga.
--datastore-username and --datastore-password override the credentials in the URI.
Share a database with OpenFGA
AgentFGA uses OpenFGA's own schema and migrations, recorded in the same goose_db_version table, so either tool can migrate the database and migrate --version N moves up or down to a version. AgentFGA and OpenFGA servers can serve one database at the same time, for example behind one load balancer during a migration. They read each other's models, tuples, conditions, change history and assertions, and accept each other's continuation tokens.
Read replicas
Set --datastore-secondary-uri to send reads to a replica, as OpenFGA does: tuple reads go to the replica unless the request asks for HIGHER_CONSISTENCY; model, store, assertion and change-history reads always do. Writes, and the reads inside a write, go to the primary.
Use more than one core
Each process uses one core. --workers N runs N processes behind the same ports, and --workers 0 runs one per CPU. The memory datastore is private to one process, so use SQLite, Postgres or MySQL with more than one worker. On a 16-core test machine sharing cores with Postgres, one worker served about 2,500 to 3,000 checks a second and four workers about 8,400.
Metrics
Prometheus metrics are served on --metrics-addr (:2112/metrics) with OpenFGA's names and labels: the grpc_server_* families, openfga_request_duration_ms, openfga_datastore_query_count, openfga_datastore_item_count, openfga_dispatch_count, openfga_check_result_count and the process_* metrics. With several workers, one scrape sums them all.
Health and shutdown
/healthz and gRPC grpc.health.v1.Health/Check answer SERVING once the database is at the schema version AgentFGA needs, and 503 NOT_SERVING until then. The server starts against an unmigrated database, so a migration job can run alongside it.
On SIGTERM or SIGINT the server stops accepting connections and finishes requests in progress, for up to --shutdown-timeout (10 s). gRPC clients get a GOAWAY.
Every response carries X-Request-Id, and every request is logged.
Database notes
Postgres behind PgBouncer in transaction mode: add default_query_exec_mode=simple_protocol to the URI, as you would for OpenFGA.
MySQL: tested against MySQL 8.4. Run it in UTC, the default in MySQL's Docker images and on RDS. In any other time zone OpenFGA reports timestamps off by the offset and AgentFGA doesn't, so the two disagree on a shared database.
OpenFGA settings AgentFGA has no use for, such as caches, tracing, throttling and the playground, are accepted with a warning. --access-control-enabled is refused at startup, so a deployment never runs with less protection than it asked for.
Run AgentFGA
Docker
The repository's Dockerfile builds the Node server into an image laid out like openfga/openfga, so it can stand in for it.
shell
docker build -t agentfga .
docker run agentfga migrate --datastore-engine postgres \
--datastore-uri 'postgres://user:pass@db:5432/openfga'
docker run -p 8080:8080 -p 8081:8081 agentfga run --datastore-engine postgres \
--datastore-uri 'postgres://user:pass@db:5432/openfga'
Ports
Port
Serves
8080
The HTTP API
8081
The gRPC API, openfga.v1.OpenFGAService and grpc.health.v1.Health
2112
Prometheus metrics
The image
The command is the container's argument: run by default, or migrate.
The health check is the same as OpenFGA's, through grpc_health_probe on port 8081.
It runs as a non-root user on a distroless Node.js base.
Run AgentFGA
Authentication
Every request must prove it may use the API, with an API key or an OIDC token. Both work on Workers and on the Node server.
API keys
The default on Workers. Clients send a key as a bearer token, and AgentFGA accepts any key in its list. Keys are compared in constant time.
# Comma-separated keys, stored as a secret.
npx wrangler secret put AGENTFGA_AUTHN_PRESHARED_KEYS
node dist/agentfga.mjs run --authn-method preshared --authn-preshared-keys key1,key2
request header
Authorization: Bearer key1
To rotate a key, add the new key next to the old one, move every client to the new key, then remove the old one.
OIDC
Accept tokens from your identity provider instead of shared keys. AgentFGA checks each token's signature against the issuer's published keys (RS256), and its issuer, audience and expiry, as OpenFGA does.
Optional settings narrow what is accepted: other issuer names (_ISSUER_ALIASES), allowed subjects (_SUBJECTS) and the claims that hold the client id (_CLIENT_ID_CLAIMS), each comma-separated. On the Node server they are the matching --authn-oidc-* flags. Clients use the SDK's client-credentials method:
Setting the method to none accepts every request. Use it only on your own machine. It is the Node server's default, as in OpenFGA, so set a method before exposing it.
A malformed request is reported as invalid before its credentials are checked, as OpenFGA does. /healthz never needs credentials.
Run AgentFGA
Configuration reference
Every setting for Workers, and the most used settings for the Node server.
Workers
Set these as vars in wrangler.jsonc, or as secrets.
Variable
Default
AGENTFGA_AUTHN_METHOD
preshared, or oidc. none turns off authentication, for local use only.
AGENTFGA_AUTHN_PRESHARED_KEYS
A secret, comma-separated. Required when the method is preshared.
Unset. Comma-separated, as OpenFGA's --authn-oidc-*.
AGENTFGA_REQUEST_TIMEOUT_MS
3000. 0 means no limit.
AGENTFGA_MAX_TUPLES_PER_WRITE
100
AGENTFGA_MAX_TYPES_PER_AUTHORIZATION_MODEL
100
AGENTFGA_MAX_CHECKS_PER_BATCH_CHECK
50
AGENTFGA_RESOLVE_NODE_LIMIT
25
AGENTFGA_LIST_OBJECTS_MAX_RESULTS / _DEADLINE_MS
1000 / 3000
AGENTFGA_LIST_USERS_MAX_RESULTS / _DEADLINE_MS
1000 / 3000
AGENTFGA_DIAGNOSTIC_HEADERS
Unset. true adds headers showing which data centers handled each request.
Node server
The Node server takes OpenFGA's flags and defaults. Each flag can also be set as an environment variable: --datastore-uri becomes OPENFGA_DATASTORE_URI. Flags win over variables. agentfga run --help lists them all.
AgentFGA covers OpenFGA's API: stores, models, tuples, Check, BatchCheck, ListObjects, ListUsers, Expand, change history, assertions and conditions. Its responses were compared with OpenFGA v1.22.0 request by request.
What was tested
Test tools send identical requests to AgentFGA and to OpenFGA, then compare every status, error code, message and response. npm test replays the results, so they are rechecked on every run.
What was compared
Requests
Differences
Random models, relationships and queries
864,827
2, both caused by OpenFGA
The same, with condition parameters sometimes missing
864,750
159, of which 157 are OpenFGA's random choice described below
Malformed and edge-case requests to every endpoint
5,020
0
gRPC requests to the Node server
1,924
0
OIDC token cases
30
0
OpenFGA's own conformance tests 171 tests
all but 18
0. The 18 use experimental $expression conditions.
The Node server was also run on one Postgres database, one MySQL database and one SQLite file alongside OpenFGA. Each wrote data and both read everything back, with every response matching.
Not supported
Experimental inline $expression conditions.
AuthZEN endpoints and OpenFGA's access control.
Tracing and gRPC server reflection.
On Workers: the gRPC API and metrics, which the Node server has.
Works differently
Stores that don't exist return 404 store_id_not_found on every route. OpenFGA accepts some requests for them, such as writing a model.
The consistency option changes nothing on Workers, where every read is up to date. On the Node server with a read replica, it routes reads as OpenFGA does.
StreamedListObjects works out the full result, then sends it.
Condition errors are worded differently, and OpenFGA's cost limit for conditions isn't enforced.
Missing condition parameters can give a different result in rare cases, because OpenFGA decides how to handle them at random.
OpenFGA bugs found during testing are not reproduced; the repository's COMPATIBILITY.md lists them.
API reference
Overview
AgentFGA serves OpenFGA's HTTP API. Requests and responses are JSON, and field names, errors and limits match OpenFGA's.
Base URL
Where it runs
Base URL
Workers, locally
http://127.0.0.1:8787
Workers, deployed
The URL npm run deploy prints
Node server
http://your-host:8080, and gRPC on port 8081
Authentication
Send an API key or OIDC token as a bearer token. See Authentication.
Ids of stores and models are 26-character ULIDs, such as 01JBZ8M2X3K4Q5R6S7T8V9W0XY.
Pages: list endpoints take page_size (1 to 100, default 50) and return a continuation_token. Pass it back to get the next page; an empty token means there are no more.
Models: queries use the store's newest model unless they name one in authorization_model_id. Responses name the model used in the Openfga-Authorization-Model-Id header.
Consistency: queries accept consistency (MINIMIZE_LATENCY or HIGHER_CONSISTENCY). See compatibility.
Health:GET /healthz answers {"status": "SERVING"} without credentials.
Errors
Errors return a status and a JSON body with a code and a message, as OpenFGA's do.
Response 400
{
"code": "validation_error",
"message": "relation 'document#approver' not found"
}
Your id for this check, unique within the request. Its answer comes back under it.
contextobject
Condition parameters for this check.
contextual_tuplesobject
{ "tuple_keys": [ ... ] } for this check only.
authorization_model_idstring
The model to use for every check. Defaults to the newest.
Response
resultobject
Answers keyed by correlation_id. Each has allowed, or an error if that one check failed; the others still answer.
One store answers about 3,600 checks a second through BatchCheck, against 500 to 700 sent one at a time. The SDKs split longer lists into batches of 50 for you.
The same query as List objects, with results sent as newline-delimited JSON, one object per line, for clients built around OpenFGA's streaming endpoint.
POST/stores/{store_id}/streamed-list-objects
Body
The same fields as List objects: type, relation and user, plus optional context, contextual_tuples and authorization_model_id.
Response
One line per object, each {"result": {"object": "..."}}.
AgentFGA works out the full result, then sends it, so the first line arrives no sooner than a List objects response would. Prefer List objects unless your client already uses this endpoint.
Lists who has a relation to an object, such as every user, team or agent that may view the Q3 plan.
POST/stores/{store_id}/list-users
Path
store_idstringrequired
The store's id.
Body
objectobjectrequired
The object as { "type": "document", "id": "q3-plan" }.
relationstringrequired
The relation to list users for.
user_filtersobject[]required
One filter naming the kind of user to return: { "type": "user" }, or a userset such as { "type": "team", "relation": "member" }.
contextobject
Condition parameters. Every condition on the way must be able to evaluate, or the request fails.
contextual_tuplesobject[]
Tuples for this request only. Unlike the other queries, this is a plain list of tuples.
authorization_model_idstring
The model to use. Defaults to the newest.
Response
usersobject[]
Each is an object ({ "type", "id" }), a userset ({ "type", "id", "relation" }) or a wildcard ({ "type" }, meaning everyone of that type). At most 1,000 by default.
A root node. Each node is a leaf (users stored directly, a computed relation, or a relation through another object) or a union, intersection or difference of child nodes. Expand usersets in the leaves with further calls.