Toby Allen

Auth0 CIBA with Real Guardian Push: Two Bugs and One Highly Regulated Identity Upgrade

· Auth0, Customer Identity Products, Identity Security, MFA

Client-Initiated Backchannel Authentication is the CIBA grant: a requesting device sends an approval request that never touches the approver's browser at all, and Auth0 pushes it straight to their Guardian app instead. I added this to my Auth0 session and token demo suite on purpose against a real Guardian push and a genuinely enrolled device, rather than the simulated approver-inbox pattern I have used in other demos to let a presenter show the mechanism without a phone in hand. That decision is what surfaced both of the bugs in this post — neither would show up against a fake inbox, because both are about exactly how Auth0's real Guardian integration behaves.

In this post I will detail the two bugs I hit getting real Guardian enrolment and push delivery working, then what changes once you add Rich Authorization Requests — the piece of Auth0's Highly Regulated Identity feature set that lets the push notification itself carry structured transaction data instead of one line of text.

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

  1. The enrolment ticket is not a QR code
  2. Auth0 calls a push enrolment "pn", not "push"
  3. What Rich Authorization Requests actually changes on the push
  4. The schema has to be registered before Auth0 will accept it

The enrolment ticket is not a QR code

Before CIBA can push anything, the approver needs Guardian enrolled. Auth0's Management API for this is a single call:

curl -X POST https://{yourDomain}/api/v2/guardian/enrollments/ticket \
  -H "Authorization: Bearer {managementToken}" \
  -d '{ "user_id": "{userId}", "factor": "push-notification" }'

The response is a ticket_url. My first pass generated a QR code from that URL myself and told the presenter to scan it with the Guardian app's own in-app scanner. Guardian rejected it outright with a plain "QR code invalid" error. Pasting the exact same URL into Guardian's "enter manually" option worked immediately, which was the clue — the URL itself was fine, I had just fed it into the wrong mechanism. Guardian's in-app scanner expects a different payload format, built for the manual "add a device" flow inside Guardian's own settings, not for ticket-based enrolment.

The actual fix was to stop generating a QR at all and just open the ticket URL directly:

window.open(ticket_url, '_blank', 'noopener,noreferrer');

Opened as a link, ticket_url lands on Universal Login's own hosted enrolment page, and that page renders its own correctly formatted QR for a phone's ordinary camera to scan. My app's job was only ever to hand the URL off, not to re-encode it.

Auth0 calls a push enrolment "pn", not "push"

With enrolment actually working, my demo still refused to move past the "waiting for enrolment" screen even after confirming the phone had enrolled. The status check was:

const enrolled = enrollments.some((e) => e.type === 'push' && e.status === 'confirmed');

A direct call to GET /api/v2/users/{userId}/enrollments against the same enrolled user showed why:

[
  {
    "id": "push|dev_NRrJs2STTOT5rOtq",
    "status": "confirmed",
    "type": "pn",
    "auth_method": "guardian",
    "name": "iPhone"
  }
]

The field is "pn", not "push". I had assumed the value without checking a real response, and the check silently never matched — no error, no failed request, just a false that looked exactly like "not enrolled yet" regardless of how long you waited. Once corrected to e.type === 'pn', the same enrolment that had been sitting there the whole time was picked up on the very next poll.

What Rich Authorization Requests actually changes on the push

Standard CIBA sends one field the approver has to trust: binding_message, a plain string up to 64 characters, rendered verbatim on the push. It is not cryptographically bound to anything — if the requesting app lets client-supplied text into that field, an approver can be talked into approving something other than what actually happens.

Rich Authorization Requests fixes the display half of that problem. Attach an authorization_details array to the same /bc-authorize call, using Guardian's own built-in schema, and Guardian renders structured, itemized fields instead of one sentence:

[{
  "type": "urn:auth0:schemas:authorization-details:v1:user-profile",
  "instruction": "Approve refund for order #4821",
  "properties": {
    "amount": { "display": true, "name": "Amount", "display_order": 1, "value": "$4,821.00" },
    "order": { "display": true, "name": "Order", "display_order": 2, "value": "#4821" },
    "action": { "display": true, "name": "Action", "display_order": 3, "value": "Refund" }
  }
}]

This is the one schema type Guardian recognises natively — no custom consent screen, no dashboard configuration for how it renders. That machinery exists for a different schema style, used with web-based consent via PAR or JAR, not for a Guardian push. This is the actual value Highly Regulated Identity adds here: the same transaction, described in a shape the approver can verify field by field rather than a sentence they have to take on faith. It does not, on its own, make the underlying data trustworthy — the same rule applies as binding_message: generate every field server-side from a verified transaction, never from client input.

The schema has to be registered before Auth0 will accept it

The first real /bc-authorize call carrying authorization_details failed:

{
  "error": "invalid_authorization_details",
  "error_description": "authorization_details indexes 0 are not permitted by the resource server configuration"
}

Even Auth0's own built-in Guardian schema is not accepted until it is registered on the resource server the CIBA client's audience points at:

curl -X PATCH https://{yourDomain}/api/v2/resource-servers/{resourceServerId} \
  -H "Authorization: Bearer {managementToken}" \
  -d '{ "authorization_details": [{ "type": "urn:auth0:schemas:authorization-details:v1:user-profile" }] }'

After that one-time registration, the same request that failed a moment earlier goes through and the push renders the itemised fields. Nothing about this step is mentioned in the schema documentation itself — I found it by reading the rejected request's error message rather than a setup guide.

Conclusion

Getting a real Guardian push working end to end turned out to need less Auth0-side configuration than I expected and more attention to my own assumptions about field values than I would like to admit. The live demo has a toggle between a standard binding_message push and the Rich Authorization Requests version, so you can trigger both against the same transaction and see the difference land on the actual phone.