Toby Allen

FGA as an API Gateway Enforcement Point: OpenFGA Behind a Cloudflare Worker

· Auth0, Auth0 FGA, Customer Identity Products, Cloudflare Workers

Auth0 FGA, Auth0's hosted build of OpenFGA, moves the authorisation decision out of the access token and into a relationship graph that a gateway can check on every request. Most of the demos in this series put the decision inside the application, a permissions claim decoded off the access token and checked in a route handler. This one puts the decision in front of the application entirely: a Cloudflare Worker validates the token, calls OpenFGA's Check API, and only forwards the request if the answer is yes.

The gap this fills is a real one. A permissions claim is static for the lifetime of the token, it's a flat list, and it has nowhere to put a relationship like "member of the team this document was shared with." Relationship-based access control (ReBAC) solves all three at once, but only if something actually calls the check on the way in. In this post I will detail the authorisation model behind the demo, how the Worker enforces it, and the one modelling mistake an adversarial review caught before any of it went live.

The steps we will cover in this post are as follows.

  1. The OpenFGA model and why the first draft of it was wrong
  2. The Worker as policy enforcement point
  3. Fail-closed, not fail-open
  4. Running the live demo

The OpenFGA Model and Why the First Draft of It Was Wrong

The demo's model is deliberately small, two document types and three test identities, enough to show the mechanism without burying it in domain complexity.

model
  schema 1.1
 
type user
 
type team
  relations
    define member: [user]
 
type document
  relations
    define owner: [user]
    define team: [team]
    define viewer: [user] or owner or member from team
    define editor: [user] or owner
    define can_read: viewer or editor
    define can_write: editor

A document has an owner, who gets both read and write. It can also be shared with a team, and anyone who is a member of that team inherits viewer. The Worker only ever checks can_read and can_write, never viewer or editor directly, so the underlying relation shape can change without touching the gateway's own logic.

The first draft of this model used viewer: [user, team#member] or owner instead of the member from team line above, and it looked completely reasonable. It validated cleanly against the OpenFGA CLI, and I ran it through an adversarial review, two independent LLM review passes given the model and the seed tuples and asked to find real problems rather than approve the design. Both caught the same thing, independently, before either saw the other's review: the [user, team#member] type restriction only accepts a userset tuple written directly on viewer, something like document:doc-002#viewer@team:platform-eng#member. It does not traverse the separate team relation the seed tuples actually populate (document:doc-002#team@team:platform-eng). Under that model, a user who should inherit access through team membership gets denied, and the test suite's own assertions would have failed the moment anyone ran them for real.

OpenFGA's own documentation names the fix: X from Y is the indirect-relationship pattern for exactly this case, inheriting a relation through an intermediate object rather than requiring a userset tuple written on every target. viewer: [user] or owner or member from team traverses the team relation correctly, and I ran the full test suite against the corrected model, fga model test, six tests, ten checks, two list-objects queries, all green, including the specific team-inheritance assertion that silently failed before. The lesson generalises past this one model: a type restriction that compiles and validates is not the same claim as a type restriction that resolves the relationship you actually seeded, and the only way to know for certain is to run the check against real tuples, not read the DSL and trust it.

The Worker as Policy Enforcement Point

The architecture splits cleanly into a policy decision point and a policy enforcement point, the same split Auth0's own writing on API gateway authorisation describes, with OpenFGA as the PDP and the Worker as the PEP sitting in front of two real routes.

A request flow diagram showing the tokens app forwarding an access token to a Cloudflare Worker, which verifies the JWT, calls OpenFGA Check, and returns allow, deny, or unavailable

  • GET /documents/:id maps to the can_read relation.
  • PUT /documents/:id maps to can_write.

Both the relation and the object are derived from the actual incoming method and path, never from anything the client asserts in the request body. An unmapped path returns 404 and a mapped path with an unsupported verb returns 405, rather than either one falling through to a default relation. A looser mapping is exactly how a write ends up silently checked as a read.

Identity arrives as a bearer token the Worker trusts rather than mints itself. The tokens Next.js app authenticates the visitor the ordinary way, requests an access token scoped to this demo's own Auth0 resource server, and forwards it server-side to the Worker. The Worker verifies the JWT with jose's createRemoteJWKSet against the tenant's JWKS endpoint, pinned to RS256 explicitly and checked for an exact audience and issuer match, then builds a single OpenFGA Check request:

const { allowed } = await fgaClient.check({
  user: `user:${sub}`,
  relation,   // can_read or can_write, from the strict allowlist above
  object,     // document:doc-001 or document:doc-002
});

The sub claim on the verified token becomes the user: identifier directly, with no transformation. That only works because the seed tuples were written against the exact sub values Auth0 issues for these three test identities in the first place; a production integration needs to decide its own convention for mapping identity-provider subjects onto OpenFGA user identifiers and seed tuples against that same convention consistently.

That call lives in exactly one file in the Worker. Nothing else in the codebase touches the OpenFGA SDK directly, a single choke point for every authorisation decision this gateway makes. Copy that part into any production equivalent regardless of what the actual relations end up being.

Fail-Closed, Not Fail-Open

The gateway distinguishes three outcomes, not two, and the third one is the part most hand-rolled integrations skip.

  • Allow (200) — the Check returned allowed: true. The Worker returns a mock document payload alongside the JWT claims and the exact Check request/response, so the demo page can show precisely what was asked and what came back.
  • Deny (403) — the Check returned allowed: false, and nothing else resolves to a deny.
  • Unavailable (503) — the Check call timed out, errored, or the Worker couldn't get a clean decision back from OpenFGA at all. This is never rendered or treated as a deny.

That third branch is a deliberate, tested design decision rather than an afterthought. If OpenFGA is unreachable, a user whose access should be denied and a user whose access should be allowed look identical from the Worker's point of view, it genuinely doesn't know, and reporting "denied" would be fabricating a verdict it has no basis for. I verified this by deliberately pointing the Worker at a broken store configuration and confirming it returns 503 in every case, never a false 403. There's no decision caching anywhere in the pipeline either, on purpose. Removing a tuple, revoking a team membership, say, has to be visible on the very next request, and a cached allow surviving that moment would mean a revoked user still gets through.

Running the Live Demo

The FGA gateway demo page ships with three test identities seeded against the model above, each one producing a different, visually distinct outcome against the same two documents.

  • alice is a direct owner of one document and a direct editor of the other, so every button returns an allow.
  • bob has no relation at all to the first document, but inherits viewer on the second through team membership, read succeeds, write still fails, because viewer was never granted can_write.
  • carol has no tuple in the store at all. There's no deny rule being evaluated for her; the graph simply has no path from her identity to either document.

The page itself now shows the real .fga model and the seed tuples alongside each identity's expected outcome, so you can read the relationship graph and then watch the gateway agree with it, rather than taking the demo's word for either half on its own.

Final Thoughts

Cloudflare Workers turned out to be the right place to start this pattern, not Apigee or another full API management platform, for a reason that has nothing to do with features: a Worker deploys in seconds against infrastructure this project already uses, at effectively zero cost for a demo's call volume, while standing up a comparable Apigee environment needs a GCP project, a billing decision, and an evaluation window measured in days rather than seconds. The architecture itself, PDP and PEP cleanly separated, a single choke point for every check, fail-closed on infrastructure errors, carries over unchanged to a heavier gateway if that's what a production deployment already has sunk cost in. If you're modelling something similar, OpenFGA's own modelling guide covers the DSL this post leans on throughout, including the X from Y pattern that saved this one from shipping broken.