Delegated access part three: approval flows and Auth0 CIBA
The previous post in this series covered the proactive case: Jane grants Chris access to her patient record, Chris accepts, a delegation is recorded. In this post I will cover the inverse - a user requests access they do not yet have, and the account holder approves it, ideally without leaving whatever they are doing at the time.
The scenario: Sam is a nurse who needs to view Jane's current medication list to coordinate care. Jane has not offered access. Sam submits a request, and the question becomes: how does that request reach Jane, and what happens when she approves it?
Determining the approver via FGA
The first thing the application needs to do when Sam submits a request is work out who can approve it. Hardcoding this would mean baking business rules into the application. Instead, the authorisation model already contains the answer.
The FGA model from part two includes a can_approve relation on the account type:
define can_approve: approver or admin or owner
When Sam requests access to a resource, the application asks FGA: who has can_approve on the account that owns this resource? In Jane's case the account owner is Jane herself, so the approver resolves to Jane. In an organisation with multiple administrators, it might resolve to any of them.
This matters for the same reason the rest of the authorisation model matters: the approval chain is not hardcoded, it is a relationship. If the account structure changes - Jane adds a clinical administrator, for example - the approver changes automatically without any code change.
Auth0 CIBA
Once the approver is identified, the application needs to notify them. Standard redirect-based OAuth requires the approver to be sitting at a browser. Auth0's Client-Initiated Backchannel Authentication (CIBA) takes a different approach: the application initiates an authentication request on the approver's behalf, and the notification is pushed to a device they already have enrolled - in Auth0's case, the Guardian app.
The diagram below shows the full flow across the four participants.
The flow has three steps.
Initiate. The application calls bc-authorize with a login_hint pointing at the approver's Auth0 identifier and a binding_message - a short human-readable string shown on the Guardian push so the approver knows what they are approving:
POST /bc-authorize
login_hint: {"format":"iss_sub","iss":"...","sub":"auth0|jane-001"}
binding_message: "Approve medications:read for sam@example.com"
scope: openid
Auth0 returns an auth_req_id and an expires_in (typically 300 seconds).
Poll. The application polls the token endpoint using the CIBA grant type until the approver acts or the request expires:
POST /oauth/token
grant_type: urn:openid:params:grant-type:ciba
auth_req_id: <value from bc-authorize>
The response is one of: authorization_pending (still waiting), access_denied (approver rejected), expired_token (window elapsed), or a token set on success.
Approve. Jane receives a Guardian push on her phone. It shows the binding message. She approves with a tap. The next poll returns a token set and the application creates the delegation.
A few practical notes. CIBA requires an Auth0 Enterprise plan and the approver must have Guardian push enrolled on their device. The binding_message must be short - Auth0 enforces a character limit - and should be alphanumeric only to avoid encoding issues.
The simulation fallback
For demos and for production environments where Guardian enrolment is not universal, it is useful to have a fallback that produces the same result without requiring a push-enrolled device.
The approach I use is an in-app approver inbox: when a request arrives, the approver sees it as a pending item the next time they visit the application. They can approve or deny from there directly. The approval path is identical - it resolves the same Firestore and FGA writes - and the audit trail records the same fields. The only difference is the notification channel.
This should be clearly labelled as a simulation in any demo context. The experience is meaningfully different from CIBA: the approver has to come to the application rather than being reached wherever they are. For customer-facing conversations the distinction matters. Showing a Guardian push to a customer's mobile mid-demo is the moment that makes the pattern concrete; an inbox approval does not land the same way.
In the demo application I use an environment flag to switch between the two modes. Simulation is the default, which means the flow works for any presenter on any device without enrolment. When the flag is off and CIBA is enabled, the application attempts bc-authorize and falls back to the inbox on error.
When approval completes
Whether the approval comes via CIBA or the inbox, what happens next is the same.
The application creates a delegation record in the data store - exactly as described in part two - and writes the corresponding FGA tuple with a not_expired condition. Sam can now view the medication list. The FGA check resolves through the delegation, and the current_time context ensures the access is time-bounded.
The diagram below shows the three records written on approval and how they relate.
This is worth naming explicitly: the approval is not the authorisation. The approval is the event that triggers the creation of a delegation, which is the authorisation. CIBA proves that Jane approved the request at a specific time on a specific device. The delegation record records what was approved. The FGA tuple enforces it at access time. Three separate things, each with a different lifetime.
Consent recording
The consent record written on approval should include enough to be useful to a compliance team months or years later. At minimum:
- Who requested access and what they requested
- Who approved it, when, and on what basis (CIBA auth_req_id or explicit inbox approval)
- What the system decided - the resulting delegation ID, the permitted scopes, the expiry
- The FGA check at access time
The audit trail ties these together via a correlation ID that spans the request:create, request:approve, and delegation:create events. If a regulator asks "did Jane consent to Sam viewing her medication list on this date?", the answer is in three linked records.
A note on CIBA consent persistence
Auth0 CIBA does not persist consent grants across sessions. Each CIBA flow is a standalone authentication event. This can be surprising in a delegated access context: you might expect that once Jane approves Sam's request, a future CIBA against the same pair would skip the prompt. It does not.
In practice this does not cause problems here, because the result of the approval - the delegation - persists independently of CIBA. Sam's subsequent access attempts go through the FGA check against the active delegation record, not through another CIBA flow. CIBA is only needed for the initial approval event. Once the delegation exists, it authorises all future access within its scope until it expires or is revoked.
The scenario where this matters is one where you want to use CIBA as a recurring step-up mechanism rather than a one-time approval trigger. That is a different use case, and it is worth knowing the limitation before designing around it.
What's next
In part four I look at what happens when a CIBA push expires before the approver responds - the 300-second window is short enough to be a real problem in production - and how to design an approval flow that is both real-time when the approver is available and reliably durable when they are not.