Implementing delegated access with Auth0 and Auth0 FGA
In the previous post I covered why delegated access is harder than it looks and why role-based access control alone is not enough. In this post I will walk through how to build it — the data model, the integration points between Auth0, Auth0 FGA and a standard application data store, and the key flows from delegation grant through to acting on someone else's behalf.
I will use a healthcare scenario throughout: Jane is a patient, Chris is her carer, and the resource in question is Jane's patient record.
What each system owns
Before getting into the flows it is worth being clear about which system is responsible for what, because getting this wrong leads to duplicated logic and synchronisation problems.
Auth0 owns user identities and credentials. It is the authentication layer — it verifies who you are, manages sessions and tokens, and enforces MFA. It does not know or care about the relationship between Jane and Chris.
Auth0 FGA owns the authorisation relationships. It stores the explicit fact that Chris has the delegate relation to Jane's patient record, and it answers the question "can Chris view appointments on this record right now?" It does not know anything about sessions or how those relationships were created.
The application data store (in this case Firestore, though any database works) owns the delegation lifecycle records — the business objects that describe a delegation: who granted it, who it was granted to, what scopes it covers, when it was created, when it expires, whether it has been revoked, and the audit trail of actions taken under it.
The diagram below shows the division of responsibility and how the systems communicate.
The key principle: FGA tuples are derived state. The application data store is the source of truth for delegations. When a delegation is created and accepted, the application writes the corresponding FGA tuple. When it is revoked, the application removes the tuple. FGA never holds state that is not a direct reflection of the application's own records.
The authorisation model
The FGA model for this scenario is straightforward. There are three types: user, account and resource. Resources belong to an account via a parent_account relation, which means account-level relationships — owner, member, admin — are inherited by every resource under that account.
The interesting addition is a conditional tuple for time-bounded delegation:
condition not_expired(current_time: timestamp, valid_until: timestamp) {
current_time < valid_until
}
When the application writes a delegate tuple, it attaches this condition and the valid_until value from the delegation record. Every authorisation check passes current_time as context. FGA evaluates the condition at check time: if the delegation has expired, the check returns denied without any scheduled job, cleanup task or polling loop.
The diagram below shows the authorisation model types and a concrete set of tuples for the Jane/Chris scenario.
This is the cleanest way to handle time-bounded access I have encountered. The expiry is enforced at the decision point, not at some earlier data-management step.
The delegation lifecycle
When Jane wants to grant Chris access to her appointments and prescription repeats, the application:
- Creates a delegation record in the data store with the delegate's identity, the target resource, the permitted scopes, and the expiry date. The status is
pending. - Sends Chris an invitation by email using Auth0's Management API. If Chris does not yet have an account, Auth0 creates one and sends a set-password link.
- When Chris accepts, the application transitions the delegation to
activeand writes the FGA tuple — including thenot_expiredcondition — to the store. - An audit event is written at each step: creation, acceptance, and every subsequent action taken under the delegation.
When Jane revokes:
- The application marks the delegation as
revokedin the data store. - The FGA tuple is immediately deleted.
- The next time Chris attempts to access the record, the FGA check returns denied.
The deletion happens synchronously in the same operation as the revocation. There is no delay, no expiry period, no grace window.
Gating sensitive actions with MFA step-up
Granting and revoking delegations are sensitive operations. Before either is allowed, the application requires the acting user to have recently completed MFA. This is standard Auth0 step-up authentication — the application redirects to Universal Login with acr_values=http://schemas.openid.net/pape/policies/2007/06/multi-factor, which forces an MFA challenge before returning.
There is one non-obvious detail here. After step-up, Auth0 issues a new ID token with updated claims. But the session.user object in the server session is set from the ID token at initial login and is not updated when a step-up issues a new one. To correctly detect whether MFA was recently completed, you need to decode session.tokenSet.idToken directly rather than reading session.user.
I added a post-login Auth0 Action that sets a timestamp claim on the ID token whenever MFA is completed. The application checks this raw token claim: if MFA was completed as part of the initial login (from the amr claim, which Auth0 populates natively), the gate passes without a time limit. If the user stepped up after a password-only login, the timestamp is checked and access is granted for five minutes before re-challenge.
The elevated session also receives additional scopes on the access token — delegation:create and delegation:revoke — via the same Action, making the MFA gate visible in the decoded token and available for downstream API enforcement.
Acting on behalf of
Once Chris has an active delegation, he can switch into "act on behalf of" mode. At this point the application needs to represent both parties: Chris as the actor performing the action, and Jane as the subject whose record is being accessed.
The approach follows the RFC 8693 token exchange claims convention. Auth0 does not natively issue user-to-user on-behalf-of tokens in this form, so the application issues a short-lived custom JWT via a secured endpoint. Before issuing the token, the endpoint re-checks the FGA delegation — honouring any expiry condition or revocation that has occurred since the session was established. The token structure is shown below — the key distinction is sub (Jane, the represented party) versus act.sub (Chris, the actor performing the action).
The token payload includes:
sub: Jane's Auth0 identifier (the represented party)act.sub: Chris's Auth0 identifier (the actor)scope: only the scopes permitted by the delegation- A custom claim referencing the delegation ID
This distinction matters for the audit trail. Every action under the token is logged with both identities: who did it (act.sub) and on whose behalf (sub). This is precisely what regulators and auditors want to see, and it cannot be reconstructed after the fact from a session that only records the actor.
The token is short-lived — fifteen minutes. If the delegation is revoked mid-session, the next API call will fail the FGA re-check and the session expires cleanly.
Checking access
For ordinary resource access — Chris loading Jane's appointment list — the flow is:
- The application calls
listObjectswith Chris's identity, the relationcan_view, and the resource type, passing the current timestamp as context. - FGA traverses the model: does Chris have
delegateon the specific appointment resource? Is thenot_expiredcondition satisfied? Does the account-levelcan_viewpath apply? - The returned list contains only the resources Chris is currently authorised to view. No other records are returned.
- The application loads those records from the data store and renders them.
The audit event records the FGA check alongside the action — which relation was checked, which object, and whether it was allowed or denied. This means the audit log is self-explanatory even months later when the delegation record itself may have been archived.
The integration points
The application I built for this runs on Vercel with Next.js. Auth0 handles authentication via Universal Login, sessions are managed server-side, and all FGA and data store access happens in server components and server actions — the browser never touches either directly.
The integration points are:
- Auth0 Management API: creating users, sending invitations, retrieving user profiles
- Auth0 Actions: post-login hook to set MFA claims and elevated scopes on the token
- Auth0 FGA: writing and deleting tuples on delegation lifecycle events; checking and listing objects on every resource access
- Application data store: storing delegation records, approval requests, audit events and resource data
- Secured OBO endpoint: verifying the FGA delegation and issuing the custom on-behalf-of token
Each component does its own job and the boundaries are clean: Auth0 does not need to know about the delegation data model, FGA does not need to know about the session, and the data store does not contain authorisation logic. Each can be reasoned about independently.
Next steps
In the next post I will look at the approval and consent flows in more detail — specifically Auth0's Client-Initiated Backchannel Authentication (CIBA) for push-based approvals, where an access request from one user triggers an approval push to another, and the audit trail that ties consent, delegation and action together for compliance purposes.