DEV Community

Mukesh
Mukesh

Posted on

Async Authorization for Unattended AI Agents: Implementing CIBA with Auth0 So Your Agent Can Ask Permission While You're Away

The problem: agents don't run when you're looking at them

Most OAuth guidance assumes a human is present at the moment authorization happens. The user clicks a login button, gets redirected to Auth0, approves a consent screen, and comes back with a token. That model breaks the instant you build an agent that's supposed to run unattended — a cron job, a daemon, a background worker that wakes up at 3 a.m. to do something on your behalf.

I ran into this directly while building a small autonomous agent that manages recurring tasks for me: renewing a subscription, rebalancing a paper-trading position, submitting a form on a third-party site. Every one of those actions needed my authorization, but I wasn't going to be at a keyboard when the agent decided to act. The naive fix — mint a long-lived API key or refresh token once and let the agent use it forever — is exactly the anti-pattern security teams warn about: a static, over-scoped credential sitting on a box with no per-action consent and no easy revocation story.

What I wanted was authorization that could happen asynchronously: the agent proposes an action, a request goes to my phone, I approve or deny it, and only then does the agent get a token scoped to that one action. That's precisely what CIBA (Client-Initiated Backchannel Authentication) was built for, and Auth0 supports it directly. This article covers what CIBA actually does, how to wire it up with Auth0, and the scoping/expiry decisions that determine whether it's actually safer than the API key you're replacing.

What CIBA is (and isn't)

CIBA is an OpenID Foundation standard (part of the OpenID Connect family) for authorizing a client that has no direct, synchronous connection to the end user's browser. Instead of redirecting the user to an /authorize endpoint, the client calls a backchannel endpoint with a hint about who the user is (an email, a phone number, or a stored login_hint_token) and a description of what it wants to do. The identity provider then notifies the user out-of-band — a push notification, an SMS, an email — and the client polls a token endpoint until the user approves or the request expires.

It's worth being precise about what this buys you over a standing credential:

  • Per-request consent. Every sensitive action can trigger a fresh approval, not just the first one.
  • Human-readable context. The push notification can show what is being authorized ("Agent wants to cancel your Vultr instance web-03"), not just an abstract scope name.
  • Short-lived, narrowly scoped tokens. You mint exactly the access needed for the action, not a broad, reusable grant.
  • A real revocation point. If you deny the push, no token is ever issued — there's nothing sitting on disk to rotate later.

CIBA is not a replacement for client credentials in machine-to-machine calls that genuinely don't involve a human decision. It's specifically for the point where an agent's action needs a human in the loop, just not a human at a browser.

Wiring it up with Auth0

Auth0 exposes CIBA as part of its "Auth for GenAI" / async authorization support on top of the standard OIDC backchannel flow. The setup has three pieces: an Auth0 API representing the resource your agent will call, an application configured for the urn:openid:params:grant-type:ciba grant, and the agent-side polling loop.

First, enable the grant type on your application in the Auth0 dashboard (Applications → your app → Advanced → Grant Types → Client Initiated Backchannel Authentication), and make sure your tenant has a notification channel configured — Auth0 supports push via a bound Guardian-enrolled device, which is the practical choice for a personal agent since it doesn't require a full IdP-managed employee directory.

On the agent side, the flow looks like this:

const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN;
const CLIENT_ID = process.env.AUTH0_CLIENT_ID;
const CLIENT_SECRET = process.env.AUTH0_CLIENT_SECRET;

async function requestApproval(action) {
  const res = await fetch(`https://${AUTH0_DOMAIN}/bc-authorize`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      login_hint: JSON.stringify({
        format: 'iss_sub',
        iss: `https://${AUTH0_DOMAIN}/`,
        sub: action.ownerUserId,
      }),
      scope: `openid ${action.requiredScope}`,
      binding_message: action.humanReadableSummary, // shown on the push
      requested_expiry: '120', // seconds the human has to respond
    }),
  });
  return res.json(); // { auth_req_id, expires_in, interval }
}

async function pollForToken(authReqId, interval) {
  while (true) {
    await new Promise((r) => setTimeout(r, interval * 1000));
    const res = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'urn:openid:params:grant-type:ciba',
        auth_req_id: authReqId,
      }),
    });
    const data = await res.json();
    if (res.ok) return data.access_token;
    if (data.error === 'authorization_pending') continue;
    if (data.error === 'access_denied') throw new Error('User denied the action');
    if (data.error === 'expired_token') throw new Error('Approval window expired');
    throw new Error(`Unexpected CIBA error: ${data.error}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

The agent's actual task loop then becomes: decide an action is needed, call requestApproval with a human-readable binding_message describing exactly what it wants to do, poll for the token, and only proceed if it gets one back before expiry. Denials and timeouts are both first-class outcomes your agent needs to handle gracefully — logging them and backing off is usually the right move rather than retrying immediately.

Scoping decisions that actually matter

The CIBA flow itself doesn't make you safe — the scope you request and the expiry you set do. A few things I learned the hard way building this:

  • Scope to the action, not the resource class. A scope like vultr:instance:destroy:web-03 beats vultr:admin even though it's more tokens to define. If your downstream API can't do per-instance scopes, encode the target in the token's custom claims and check it server-side before executing.
  • Keep requested_expiry short. 60–120 seconds is enough for a phone notification tap. Long windows turn an approval into something a stale, forgotten notification can accidentally satisfy.
  • Make binding_message genuinely descriptive. "Approve action" trains you to tap without reading. "Cancel Vultr instance web-03 ($48/mo)" gives you enough information to actually say no.
  • Log every bc-authorize call, approved or not, independent of Auth0's own logs — for an unattended agent, your own audit trail is often the first place you'll look when something unexpected happened at 3 a.m.

The result is a system where the agent can act autonomously most of the time, but the moment it needs to do something consequential, it has to ask — and it has to ask in a way you can actually evaluate, not just approve reflexively. That's a meaningfully different security posture than a service account key sitting in an environment variable, and it's a pattern that generalizes well beyond the specific example here to any agent that needs to act on a human's behalf without the human being present at the moment of the request.

Top comments (0)