Here's a scenario worth thinking about.

A financial reconciliation agent is running. It authenticated correctly, the token is valid, every check at session start passed. About one minute in it reads 847 invoices in 12 seconds. The baseline rate is 15 per minute.

The authorization system doesn't react. It granted access at the start of the session and hasn't been asked again. The anomaly runs to completion. 

 I've watched this exact postmortem happen more than once. Nobody's negligent, the logs are all there, it's just that nobody was watching them in real time.

The access control wasn't misconfigured. It just wasn't designed to respond to what happens during a session, only to what's true at the start. Per-invocation policy evaluation helps, but decisions are only as good as the context they evaluate against. If risk signals can't reach the Policy Decision Point (PDP) while the agent is running, you're still making stale decisions. 

That's the core problem this piece is about: authorization can't really be "runtime" if the risk context feeding it is stale.

The Missing Piece

What's needed is an event channel, a way for runtime observations to feed back into authorization context in near-real-time. When something anomalous happens, that fact should reach the PDP before the next tool call, not at the next login.

This is the part I find most underrated in identity architecture conversations.

The OpenID Shared Signals Framework (SSF) is a standard that provides a useful foundation for this. It handles asynchronous propagation of security events between systems. Originally designed for user sessions, an IdP detecting a credential compromise and notifying downstream apps, it maps directly onto agent authorization.

The core unit is a Security Event Token (SET): a signed JSON Web Token (JWT) carrying an event from a transmitter to a receiver. Your transmitters are things like the agent runtime, your identity provider, and your EDR system. Your receiver is a risk engine that maintains per-agent risk scores and pushes updates to the PDP.

SSF defines two standard event profiles:

  • Continuous Access Evaluation Protocol (CAEP), session lifecycle events: session revoked, assurance level changed, device compliance changed, token claims changed
  • Risk and Incident Sharing and Coordination (RISC), identity risk: credential compromise, account disabled

That's the extent of what the standard itself defines. Agent-specific behavioral events, the risk-scoring model, and every threshold used for the rest of this piece are a pattern built on top of SSF, not part of the specification. For agent-specific behavioral signals, you can define custom types under your own namespace, and the standard has nothing to say about how those events should affect a risk score. That part is yours to design.

Setting Up the Stream

The risk engine registers with each transmitter to subscribe to the event types it cares about:

POST /sse/stream
Host: idp.acmecorp.com

{
  "delivery": {
    "method": "urn:ietf:rfc:8936",
    "poll_endpoint": "https://idp.acmecorp.com/sse/poll"
  },
  "events_requested": [
    "https://schemas.openid.net/secevent/caep/event-type/session-revoked",
    "https://schemas.openid.net/secevent/caep/event-type/assurance-level-change",
    "https://schemas.openid.net/secevent/risc/event-type/credential-compromise",
    "urn:acmecorp:secevent:agent/anomalous-behavior",
    "urn:acmecorp:secevent:agent/privilege-escalation-attempt"
  ]
}

The last two are custom agent events. Standard CAEP doesn't cover behavioral anomalies specific to autonomous agents, so you extend it.

What an Agent Event Looks Like

When the agent runtime detects the bulk invoice reads, it publishes a SET to the event bus:

{
  "iss": "https://agent-runtime.acmecorp.com",
  "jti": "evt-agent-anom-00391",
  "iat": 1736949120,
  "aud": "https://risk-engine.acmecorp.com",
  "sub_id": {
    "format": "iss_sub",
    "iss": "https://agent-runtime.acmecorp.com",
    "sub": "agent:payments-reconciler-v2"
  },
  "events": {
    "urn:acmecorp:secevent:agent/anomalous-behavior": {
      "anomaly_type": "unexpected_tool_access",
      "details": "Agent accessed 847 invoices in 12 seconds; baseline is 15/min",
      "recommended_risk_delta": 55,
      "tool_invoked": "invoice-read-api",
      "event_timestamp": 1736949115000
    }
  }
}

The SET is a signed JWT. The risk engine validates the signature against the transmitter's JWKS before acting on it. This matters: an agent can't self-publish events to lower its own risk score.

A few design choices worth copying:

  • Include recommended_risk_delta - The emitter has context on severity that the risk engine doesn't. Let it suggest a delta rather than forcing the risk engine to infer from event type alone.
  • Put enough context in the event body that the receiver can act without a follow-up API call.
  • Use a vendor namespace (urn:yourorg:secevent:agent/...) for custom types.

Standard CAEP events feed into the same channel. If Alice's MFA token gets revoked while her delegated agent is running, the IdP emits an assurance level change event, and any agent running under her delegation automatically gets constrained on the next authorization check, without the agent knowing or doing anything.

Turning Signals Into Authorization Context

None of what follows is prescribed by SSF. It's one way to wire the standard into an authorization decision loop, and the specific delta values and thresholds below are a starting point to tune for your own environment, not a spec to follow.

The risk engine maintains a per-agent score. Each event type maps to a delta:

RISK_DELTAS = {
    "anomalous-behavior":            +55,
    "privilege-escalation-attempt":  +60,
    "assurance-level-change":        +30,
    "session-revoked":               +100,
    "credential-compromise":         +80,
    "device-compliance-change":      +40,
}

When an event carries its own recommended_risk_delta, as in the SET above, the engine uses that instead of the table default. A couple of event types only apply their delta when the change is adverse: an assurance level change only counts when it's a decrease, a device compliance change only counts when the device becomes non-compliant.

The part that actually matters for the staleness problem: every time a new score is computed, the PDP's cached copy is invalidated immediately.

pdp_context_cache.invalidate(subject_id=subject)
pdp_context_cache.update(subject_id=subject, risk_score=new_score)

if new_score >= 80:
    publish_session_revoked_event(subject, reason="risk_threshold_exceeded")

The PDP caches risk scores with a short TTL, 30 seconds is a reasonable starting point, so it isn't querying the risk engine on every single invocation. The invalidation call is what keeps that cache from going stale the moment something changes. Without it, the PDP would keep serving decisions against the last cycle's score until the TTL expired on its own, which is exactly the failure mode from the opening scenario.

What Happens When Risk Changes

Here's how this plays out for the reconciliation agent:

  • T=0:00: Session starts. payments-reconciler-v2 is delegated by Alice. risk_score: 0, scope: [read:invoices, read:payment_records].
  • T=0:05: First invoice read. PDP evaluates with risk_score: 0. Policy allows it (the same read policy from part one requires risk_score < 50).
  • T=1:05: Runtime detects 847 reads in 12 seconds. Publishes an anomalous-behavior SET with recommended_risk_delta: 55. Risk engine: new_score = 0 + 55 = 55. PDP cache invalidated immediately.
  • T=1:07: Agent calls invoice-read-api again. PDP evaluates with risk_score: 55, which now exceeds the < 50 threshold for invoice reads. The PEP blocks the call and returns a denial citing the risk score.

The blocked attempt is itself logged as a signal. A repeated access attempt after denial adds another +25: new_score = 80.

  • T=1:09: 80 crosses the revocation threshold. The risk engine publishes a CAEP session-revoked event directly to the PDP, citing the risk score that triggered it.
  • T=1:10: Every subsequent call from this agent is denied: session revoked. The orchestration layer logs the full event chain, notifies Alice, and suspends the agent pending review.

Anomaly detected to full lockout: about five seconds.

Delegated Agents Inherit Parent Risk

If the reconciliation agent had spawned a sub-agent, that sub-agent's authorization context carries the parent's risk score:

"context": {
  "parent_session_id": "sess-9f3b2d1a",
  "parent_risk_score": 55
}

The Rego policy for delegated agents checks it:

package acmecorp.agents.delegation

import rego.v1

default allow := false

allow if {
    input.subject.properties.agent_type == "delegated"
    input.subject.properties.delegation_depth <= 2
    input.subject.properties.risk_score < 30
    input.context.parent_risk_score < 50
}

deny if {
    input.subject.properties.delegation_depth > 2
}

deny if {
    input.context.parent_risk_score >= 80
}

When the parent's score updates, the risk engine pushes a context update for all child sessions. The sub-agent gets constrained automatically, without re-authenticating. This is the failure mode that worries me most with multi-agent setups: a compromised orchestrator whose children keep running on a risk score that no longer reflects reality.

Implementation Lessons

A few things I'd insist on if I were setting this up again:

  • Cache TTLs matter more than you'd think. Risk score cache at 30 seconds is a reasonable starting point. Session state at 10 seconds. For session revocation events specifically, bypass the cache entirely and push directly to the PDP context store, you want that to be immediate.
  • Sign everything. If agents can publish unsigned events to the risk engine, they can manipulate their own scores. Every SET should be a signed JWT, validated against the transmitter's registered JWKS.
  • Log every evaluation, with enough detail (decision, risk score at that moment, session ID) to reconstruct why a call was denied after the fact. Without it, a revoked session gives you a dead end rather than a traceable incident.
  • Start with what your IdP already sends. Support for SSF and CAEP varies by vendor and by event type, so check your IdP's own documentation rather than assuming it's there. Where that support exists, you get real signals without building a transmitter yourself. Custom agent events still need to be built on top either way. caep.dev is a useful place to check current transmitter and receiver implementations if you want to see what's actually out there.

This post builds on the authorization model laid out in Part 1: AI Agents and the Authorization Gap. Start there if you haven't already, or jump back in anytime.

Share post: