Scopes, Roles, and Relationships: Picking the Right Authorization Model

·4 min read

authorizationrbacscopesarchitecture

Three mechanisms get used interchangeably and shouldn't be. They answer different questions, and picking the wrong one shows up later as either a security hole or a token you can't fit in an HTTP header.

Scopes: what may this application do?

Scopes are an OAuth2 concept, and they're about delegation. When a user authorizes an app, scopes are the subset of that user's authority being handed over.

scope=read:invoices write:invoices

That means: this application may read and write invoices, on this user's behalf. It says nothing about whether the user themselves has that permission — the authorization server is expected to narrow the grant to the intersection.

The consent screen is the giveaway. Scopes are the thing you show a user when they connect a third-party app. If your authorization model can't be sensibly displayed on a consent screen, it probably isn't a scope.

Use for: third-party apps, API surface boundaries, coarse capability grouping. A handful per API, not hundreds.

Roles: what may this user do?

RBAC answers a different question: what is this person allowed to do, within a tenant or organization?

roles=["billing-admin"]

Roles are yours, not the protocol's — you define them, assign them, and interpret them. They're coarse and organization-wide: an editor can edit, everywhere in that org.

Roles belong in the token because they're small, stable, and needed on nearly every request. Fetching them per request means a lookup on every call.

Use for: user permissions inside your product, admin tiers, per-organization membership.

Relationships: may this user do this to that thing?

Neither of the above answers "can Alice edit document 4471?" That depends on a relationship between a specific subject and a specific resource — she owns it, or it's in a folder shared with her team, or she was granted access directly.

This is ReBAC, popularized by Google's Zanzibar paper and implemented by SpiceDB, OpenFGA, and similar. Permissions are computed from a graph:

document:4471#editor@user:alice
document:4471#parent@folder:q3-reports
folder:q3-reports#viewer@group:finance#member

Alice can view the document if she's a direct editor, or a member of a group with access to the parent folder. That's a graph traversal, not a claim lookup.

Use for: sharing, hierarchical resources, per-object permissions, "who has access to this?" queries.

The mistake: putting relationships in the token

The failure looks like this:

{
  "sub": "alice",
  "permissions": [
    "doc:4471:edit", "doc:4472:view", "doc:4473:view",
    "doc:4474:edit", "doc:4475:view"
  ]
}

It works with five documents. It does not work with five thousand.

Three things break:

Size. JWTs travel in the Authorization header on every request. Most servers cap header size around 8KB — nginx defaults to 8KB total, and many load balancers are stricter. A few hundred entries and requests start failing with a 431 that looks nothing like an authorization problem.

Staleness. Tokens are valid until they expire. Revoking access to one document doesn't invalidate an issued token, so a user keeps access for the remainder of the token's life. Shortening lifetimes reduces the window and increases refresh load — you're trading one problem for another.

Issuance cost. Every token now requires enumerating every resource the user can touch. That's a expensive query on the hot path of every login and refresh.

Where things belong

question mechanism lives in
may this app act for the user? scope token
what is this user's role here? role token
may this user touch this object? relationship authorization service, checked per request

The pattern that scales: coarse in the token, fine-grained at the point of use. The token establishes who the user is, which tenant they're in, and their role. Object-level checks happen in your application against a store built for it.

Concretely:

// From the token — cheap, no I/O.
if (!token.roles.includes("editor")) return forbid();

// From the authorization service — only for the object being touched.
if (!await authz.check("user:" + token.sub, "edit", "document:" + id)) {
  return forbid();
}

The first check is free and eliminates most requests. The second runs only for requests that got past it, against one specific object.

Starting simpler

You probably don't need ReBAC yet. Roles plus organizations covers a large share of B2B SaaS, and adding a permissions service before you have sharing semantics is premature.

The signal that you need it: you're adding an owner_id check to every query by hand, and the rules are starting to differ per resource type. That's a graph trying to emerge.

What matters early is not backing yourself into the token. Keep object-level decisions out of your claims, and swapping the mechanism later is a contained change rather than a re-architecture.

Seeing what's in your tokens

Paste an access token into the JWT Decoder and look at the claim sizes — if the permissions array is the largest thing in there, that's the warning sign above. The JWT Generator is useful for testing how your API behaves with different role and scope combinations before wiring up real ones.

For roles and organization membership on AuthAction, see the RBAC docs and organizations.


Roles and organizations, built in

AuthAction ships RBAC, organizations, and per-tenant membership — so coarse authorization lives in the token and fine-grained checks stay in your app.

Free, unlimited users. No credit card required.