When teams deploy their first AI agent, security conversations almost always focus on authentication. What token does the agent carry? Is it short-lived? How does it prove its identity?

These are the right questions. But they're not the only ones.

Authentication answers ‘who is this’? Authorization answers ‘what is this allowed to do, right now’? For agents, that second question is where things quietly fall apart.

The Service Account Trap

The typical first move: create a service account, assign it the permissions the agent seems to need, and then move on. It works in testing, passes code review, and then, in production, the agent starts touching things it shouldn't.

I've seen this play out the same way across different teams and different agent frameworks, which is what makes it worth writing about. One team gave a support ticket agent read access to a customer records table. They expected it to behave like the human agents it replaced, pulling up one account at a time to answer a question. Within days it was issuing broad queries that swept up thousands of records per session to generate summary reports. This was well within its granted permissions and nowhere near what anyone had pictured when they granted them. It isn't one team's misconfiguration. It's a structural mismatch between how authorization was designed and how agents actually behave.

Access patterns aren't fixed. A user with "read customer records" access reads roughly the same things every session. An agent with the same permission might read one record to answer a ticket, or bulk-read thousands for an analysis job. Same permission, but a completely different behavior.

Generally speaking, many authorization models were built for point-in-time checks. The classic model: evaluate access once when the request arrives, grant it for the session duration. For humans logging in and out, that is fine. For an agent running continuously for hours and making dozens of tool calls per minute, a single gate at session start is almost no gate at all.

Policies drift from reality. The governance doc says "agents may only access customer records during active support sessions." The service account permission says nothing about active sessions. These two things diverge gradually. Nobody notices until an incident.

What Actually Needs to Change: Move Authorization to the Tool Call

The fix isn't to lock agents down so tightly they can't work. It's to move from assigned permissions to evaluated decisions.

Every tool invocation should be its own authorization check, evaluated against what the agent is doing, why, and what the current context looks like. If context changes mid-session, decisions change too.

This is what the AuthZEN Authorization API from the OpenID Foundation is built for: a standard HTTP API for externalized, per-request policy evaluation.

The model is straightforward. A Policy Enforcement Point (PEP) sits between the agent and its tools. On every tool call, the PEP asks a Policy Decision Point (PDP): is this allowed? None of this is trivial. Standing up a PDP, wiring a PEP into every tool call, and maintaining a growing set of Rego policies is real operational work, not a small config change.

Agent → [PEP] → Tool A
Agent → [PEP] → Tool B
Agent → [PEP] → Tool C

One thing that trips people up: putting the PEP before the agent instead of before the tools.

# Wrong
[PEP] → Agent → Tool A
              → Tool B
              → Tool C

If the PEP is at the agent boundary, you're back to a single point-in-time check. The agent passes it once and then calls whatever it wants. The PEP needs to be at the tool boundary.

The Authorization Request

When the agent calls a tool, the PEP sends a request to the PDP that looks like this:

POST /access/v1/evaluation
Host: pdp.acmecorp.com

{
  "subject": {
    "type": "agent",
    "id": "agent:payments-reconciler-v2",
    "properties": {
      "purpose": "reconcile_q4_invoices",
      "risk_score": 15,
      "delegated_by": "user:alice@acmecorp.com",
      "delegation_scope": ["read:invoices", "read:payment_records"]
    }
  },
  "action": { "name": "read" },
  "resource": {
    "type": "invoice",
    "id": "inv-2024-Q4-00847"
  },
  "context": {
    "time": "2025-01-15T14:32:00Z",
    "session_active": true
  }
}

The PDP returns allow or deny:

{ "decision": true }

Or if something has changed:

{
  "decision": false,
  "context": {
    "reason_admin": {
      "en": "Agent risk score (87) exceeds threshold for financial resource access."
    }
  }
}

Notice the risk_score field in the subject. That's not decoration. It's the field the policy actually evaluates against, and it's what makes the decision risk-adaptive instead of static.

Writing Policies That Encode Intent

The PDP runs these decisions against a policy. Using Open Policy Agent and Rego:

package acmecorp.agents.payments

import rego.v1

default allow := false

allow if {
    input.subject.type == "agent"
    input.action.name == "read"
    input.resource.type in {"invoice", "payment_record"}
    input.subject.properties.purpose == "reconcile_q4_invoices"
    input.subject.properties.risk_score < 50
    input.resource.properties.owner_org == "acmecorp"
    "read:invoices" in input.subject.properties.delegation_scope
}

deny if {
    input.subject.type == "agent"
    input.action.name == "write"
    input.resource.type == "reconciliation_report"
}

default decision := false

decision if {
    allow
    not deny
}

Compare this to a service account permission. The service account says "this identity can read invoices." This policy says "this agent can read invoices when it's doing reconciliation and its risk score is below 50 and the resource belongs to this org and it was explicitly delegated that scope."

That's the difference between granting access and justifying it, and it's the difference a compromised session actually costs you.

default allow := false is not optional. If nothing explicitly permits the action, it doesn’t happen.

The explicit deny rule for reconciliation reports matters too. Not just "doesn't grant write access" but actively blocks it. Relying on the absence of an allow rule is weaker than you think.

What to Track Per Agent

For policies like the above to work, agents need to be modeled as proper subjects, not just service accounts with a descriptive name. The minimum:

INSERT TABLE

The delegation_scope field is worth emphasizing. When a user delegates a task to an agent, they should pass only the permissions needed for that task, not their full access set. An agent running a reconciliation job should get ["read:invoices", "read:payment_records"], not everything Alice can do.

Pre-flighting Multi-Step Workflows

For longer workflows, AuthZEN supports batch evaluation before the workflow starts:

POST /access/v1/evaluations

{
  "subject": { ... },
  "evaluations": [
    { "action": { "name": "read" },  "resource": { "type": "invoice" } },
    { "action": { "name": "read" },  "resource": { "type": "payment_record" } },
    { "action": { "name": "write" }, "resource": { "type": "reconciliation_report" } }
  ]
}
Response:
{
  "evaluations": [
    { "decision": true },
    { "decision": true },
    { "decision": false }
  ]
}

The agent finds out before starting that it lacks write access to the report. It can request elevated permissions or fail fast, rather than running a multi-minute workflow that errors out at the last step.

The Remaining Gap

Per-invocation decisions against purpose-aware, risk-aware policies are a significant improvement over static service accounts. Policy changes take effect on the next call without agent restarts. Delegation scope is locked at session creation, and the blast radius of a compromised session shrinks considerably.

What this setup doesn’t solve is what happens when the context changes mid-session. The risk score in the AuthZEN request is only as fresh as whatever put it there. If an anomaly happens mid-session, bulk access, a credential change, a device going out of compliance, the static context in the request won't reflect it.

That requires a live event channel feeding into the PDP's context. That's what the OpenID Shared Signals Framework provides, and it's where the interesting runtime behavior lives.

This is the first installment in a two-part series. Continue with Part 2: The Missing Feedback Loop in AI Agent Authorization to see how runtime risk signals close the gap this post leaves open.

Share post: