<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://maledias.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://maledias.github.io/" rel="alternate" type="text/html" /><updated>2026-03-02T22:18:32+00:00</updated><id>https://maledias.github.io/feed.xml</id><title type="html">maledias</title><subtitle>Personal blog</subtitle><entry><title type="html">Fine-Grained Tool Authorization for AI Agents</title><link href="https://maledias.github.io/2026/03/02/fine-grained-tool-authorization-for-ai-agents.html" rel="alternate" type="text/html" title="Fine-Grained Tool Authorization for AI Agents" /><published>2026-03-02T00:00:00+00:00</published><updated>2026-03-02T00:00:00+00:00</updated><id>https://maledias.github.io/2026/03/02/fine-grained-tool-authorization-for-ai-agents</id><content type="html" xml:base="https://maledias.github.io/2026/03/02/fine-grained-tool-authorization-for-ai-agents.html"><![CDATA[<p>AI agents have tools. Which tools any given user should be able to invoke — and under what conditions — is an authorization problem. It’s one many agent developers don’t encounter until they’re deep in implementation, and one the industry has been solving for decades in traditional software.</p>

<p>This post applies those solutions to agents.</p>

<p>There are no implementation tutorials here. What follows is conceptual: a grounding in the authorization models — RBAC, ABAC, and ReBAC — and how each applies to agent tool authorization. Along the way, we’ll cover where enforcement happens in an agent architecture and the mechanisms available for implementing it: OAuth scopes, pre-dispatch middleware, and policy engines such as OPA, Cedar, and Cerbos. The goal is a clear mental model — what each approach does, when it’s the right choice, and how the pieces fit together.</p>

<p>If you haven’t had to think about this yet, this post is for you too. Many agent developers ship their first version without tool-level authorization and reach for these patterns only when a compliance requirement, a security concern, or a product decision forces the question. The concepts are easier to apply when you’ve seen the full map first.</p>

<h1 id="fine-grained-tool-authorization-for-ai-agents">Fine-Grained Tool Authorization for AI Agents</h1>

<h2 id="1-the-problem-hiding-in-plain-sight">1. The problem hiding in plain sight</h2>

<h3 id="11-the-agent-and-its-tools">1.1 The agent and its tools</h3>

<p>Agents have access to tools. But which tools an agent should be able to invoke depends on who is talking with it — not every user should have access to every capability.</p>

<p>To make this concrete, imagine you’re building an internal operations assistant for your company. It can search documentation, file tickets, approve expenses, pull financial reports, query databases, and manage user accounts. Useful across the whole organization — but not every one of those capabilities should be available to every user. An employee searching docs is fine. That same employee approving expenses or running arbitrary database queries is not.</p>

<p>Throughout this post, we’ll use this assistant as a running example. It has eight tools that span a natural sensitivity spectrum:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">search_docs</code>, <code class="language-plaintext highlighter-rouge">create_ticket</code>, <code class="language-plaintext highlighter-rouge">send_notification</code></li>
  <li><code class="language-plaintext highlighter-rouge">approve_expense</code>, <code class="language-plaintext highlighter-rouge">view_financials</code>, <code class="language-plaintext highlighter-rouge">export_report</code></li>
  <li><code class="language-plaintext highlighter-rouge">query_database</code>, <code class="language-plaintext highlighter-rouge">manage_users</code></li>
</ul>

<p>Three types of users will interact with it: employees, managers, and admins — each with a different scope of responsibility. Who should be able to invoke which tools, and how do you enforce that? That’s what this post is about.</p>

<h3 id="12-meet-the-users">1.2 Meet the users</h3>

<p>The three user types aren’t arbitrary. They reflect real differences in responsibility and trust.</p>

<p><strong>Employees</strong> are the general user base. They use the assistant for day-to-day tasks: finding information, filing tickets, sending notifications. Nothing they do carries significant risk.</p>

<p><strong>Managers</strong> can do everything employees can, plus actions that carry real-world consequences: approving expenses, accessing financial data, exporting reports. These aren’t tools you want every employee to have by default.</p>

<p><strong>Admins</strong> have the broadest access. On top of what managers can do, they can query internal databases and manage user accounts — capabilities that, if misused, could affect the whole system.</p>

<pre><code class="language-mermaid">graph TD
    subgraph Admin
        subgraph Manager
            subgraph Employee
                sd[search_docs]
                ct[create_ticket]
                sn[send_notification]
            end
            ae[approve_expense]
            vf[view_financials]
            er[export_report]
        end
        qd[query_database]
        mu[manage_users]
    end
</code></pre>

<p>Each level inherits the tools of the one below it and adds its own. It’s a simple hierarchy, but it captures something important: access should reflect responsibility, and different users genuinely need different things from the same agent.</p>

<h3 id="13-the-gap">1.3 The gap</h3>

<p>The hierarchy we just described is what you’d want. The problem is that it’s not what you get by default.</p>

<p>When you give an agent a set of tools, every user who interacts with it can invoke every tool. There’s no built-in enforcement. The agent doesn’t know that employees shouldn’t be approving expenses, or that database queries should be restricted to admins. It just has tools, and it will use them for whoever asks.</p>

<pre><code class="language-mermaid">graph LR
    E([Employee])
    M([Manager])
    A([Admin])
    Agent[[Agent]]

    E &amp; M &amp; A --&gt; Agent

    Agent --&gt; sd[search_docs]
    Agent --&gt; ct[create_ticket]
    Agent --&gt; sn[send_notification]
    Agent --&gt; ae[approve_expense]
    Agent --&gt; vf[view_financials]
    Agent --&gt; er[export_report]
    Agent --&gt; qd[query_database]
    Agent --&gt; mu[manage_users]
</code></pre>

<p>All three users. Same agent. Same tools. No differentiation.</p>

<p>Closing the gap requires answering two questions — and the order matters.</p>

<p>The first question is non-negotiable: <strong>which tools is this user allowed to invoke?</strong> This is answered before the agent starts, and the answer determines exactly which tools the agent is given. If a user can’t access a tool, the agent shouldn’t know the tool exists. Checking permissions at call time and returning an error is not a substitute — it still means the agent has the tool, can reason about it, and can attempt to use it. The access boundary must be set at instantiation, not enforced reactively.</p>

<p>The second question is additive: <strong>with these specific parameters, is this particular invocation allowed?</strong> This governs fine-grained enforcement within the tools the user already has access to — conditions like approval thresholds, time windows, or resource ownership that go beyond whether the tool is available at all.</p>

<p>The rest of this post is about how to answer both.</p>

<h2 id="2-why-this-is-worth-solving">2. Why this is worth solving</h2>

<h3 id="21-compliance">2.1 Compliance</h3>

<p>In regulated industries, access controls aren’t a best practice — they’re an audit requirement. Frameworks like SOC 2, HIPAA, and GDPR share a common thread: users should only be able to access what their role justifies, and every access event should be traceable to a specific person.</p>

<p>Agents complicate both of these.</p>

<p>SOC 2 requires demonstrating that sensitive data is accessed only by authorized personnel. If your ops assistant lets any employee call <code class="language-plaintext highlighter-rouge">view_financials</code> or <code class="language-plaintext highlighter-rouge">export_report</code>, you can’t make that demonstration — regardless of what your role system looks like elsewhere in your application.</p>

<p>HIPAA is more explicit. Its “minimum necessary” standard requires systems to limit access to the information strictly needed for a given task. An agent with a flat tool set has no concept of minimum necessary. It will use whatever tools seem helpful.</p>

<p>GDPR’s data minimization principle follows the same logic. An agent that can access more data than the invoking user is entitled to violates the spirit of the regulation, even if it isn’t actively misused.</p>

<p>The operational risk compounds all of this. When an agent acts on a user’s behalf, audit logs tend to record the agent as the actor — not the user who initiated the conversation. Without tool-level authorization tied to user identity, it becomes difficult to reconstruct who triggered what, which is precisely the question auditors ask.</p>

<p>Tool-level authorization isn’t just about preventing misuse. In regulated contexts, it’s what makes the system auditable at all.</p>

<h3 id="22-business-tiers-and-feature-gates">2.2 Business tiers and feature gates</h3>

<p>Not every reason to limit tool access is about compliance or security. Sometimes it’s purely a product decision.</p>

<p>If you’re building an agent as a feature of a SaaS product, the tools the agent can invoke are a direct expression of what each customer tier is paying for. A free user gets <code class="language-plaintext highlighter-rouge">search_docs</code>. A Pro customer gets <code class="language-plaintext highlighter-rouge">export_report</code>. An Enterprise customer gets the full set. The access model isn’t enforcing security — it’s enforcing the product.</p>

<p>The same pattern applies to internal tools. An organization might roll out a powerful capability like <code class="language-plaintext highlighter-rouge">query_database</code> gradually, starting with one team before expanding access. Or it might restrict tools that trigger expensive operations — certain capabilities carry real infrastructure costs — to the users whose work justifies them.</p>

<p>In all of these cases, tool-level authorization is doing the same job: making sure the right users have access to the right capabilities, for reasons that have nothing to do with threats or regulations. It’s product design, enforced at the agent layer.</p>

<h3 id="23-security-and-least-privilege">2.3 Security and least privilege</h3>

<p>The principle of least privilege has been a cornerstone of security for decades: give a system only the permissions it needs to do its job, and nothing more. If something goes wrong, the damage is bounded by what the system can actually do.</p>

<p>Agents need this more than most.</p>

<p>Unlike traditional software, agents interpret natural language. That means the boundary between intended and unintended behavior is fuzzier. An agent can be manipulated through its inputs — a technique called prompt injection — into taking actions its operator never intended. It can also make reasoning mistakes that lead it to invoke tools in ways the developer didn’t anticipate.</p>

<p>In both cases, the blast radius is determined by which tools the agent has access to. An agent that can only call <code class="language-plaintext highlighter-rouge">search_docs</code> and <code class="language-plaintext highlighter-rouge">create_ticket</code> can’t do much damage if something goes wrong. An agent that also has access to <code class="language-plaintext highlighter-rouge">manage_users</code> and <code class="language-plaintext highlighter-rouge">query_database</code> is a different story.</p>

<p>This is why authorization for agents has to be an enforcement problem, not a reasoning problem. The agent’s tool set must be defined by deterministic code that reads the user’s permissions — not by the agent reasoning about what it should or shouldn’t do. You can’t rely on a model to implement a security boundary. That boundary belongs in the authorization layer, before the agent is ever instantiated.</p>

<h2 id="3-the-challenge-agents-introduce">3. The challenge agents introduce</h2>

<h3 id="31-how-traditional-apps-enforce-authorization">3.1 How traditional apps enforce authorization</h3>

<p>Before getting into what makes agents different, it helps to understand how authorization works in a traditional application — because the patterns are well established and agents can learn from them.</p>

<p>In a typical web application, authorization operates at two levels. The first is the UI: the application knows who is logged in, and it renders only the actions that user is allowed to take. Buttons are disabled. Menu items are hidden. An employee using the ops assistant we described earlier would never see an “Approve Expense” button — it simply isn’t there for them.</p>

<p>The second level is the backend. The server independently validates permissions before executing any action. This isn’t redundancy for its own sake — it ensures that authorization is enforced consistently as a proper second layer of defense.</p>

<pre><code class="language-mermaid">graph LR
    U([User]) --&gt; UI
    UI --&gt;|"renders permitted actions"| View[Permitted UI]
    View --&gt;|"triggers action"| API
    API --&gt;|"validates permission"| Auth[Authorization Layer]
    Auth --&gt;|allow / deny| Action[Action]
</code></pre>

<p>The specific mechanism that powers these checks — role-based rules, attribute conditions, policy engines — is something we’ll cover in detail later. But regardless of implementation, the structure is consistent: authorization shapes both what the user sees and what they can actually do.</p>

<p>The same two-layer structure applies to agents. The first layer determines which tools the agent is instantiated with — the equivalent of the UI rendering only permitted actions. The second layer handles fine-grained enforcement at invocation time: not whether the user can access the tool (that was already decided at instantiation), but whether this specific invocation with these specific parameters is permitted. Getting both right is what tool-level authorization for agents is about.</p>

<h3 id="32-what-changes-with-agents">3.2 What changes with agents</h3>

<p>In a traditional application, the user’s intent is explicit. Clicking “Approve Expense” means exactly one thing: invoke the approve-expense endpoint. The action is discrete, the mapping is direct, and the authorization check is straightforward.</p>

<p>With an agent, none of that is guaranteed.</p>

<p>The user sends a message in natural language: “Can you take care of the pending expenses from last week?” The agent interprets that message, decides what it means, and determines which tools to call to fulfill it. It might call <code class="language-plaintext highlighter-rouge">view_financials</code> to look up pending expenses, then <code class="language-plaintext highlighter-rouge">approve_expense</code> for each one. Or it might do something slightly different depending on how it reasons about the request. The developer didn’t specify the action — the agent inferred it.</p>

<pre><code class="language-mermaid">graph LR
    U([User]) --&gt;|"natural language intent"| Agent
    Agent --&gt;|"interprets and decides"| Tools
    Tools --&gt; t1[view_financials]
    Tools --&gt; t2[approve_expense]
    Tools --&gt; t3[...]
</code></pre>

<p>This indirection is what makes authorization harder. In the traditional model, you authorize a specific action the user explicitly asked to perform. In the agent model, you authorize a tool set — a range of capabilities the agent might decide to invoke on the user’s behalf, based on its own interpretation of their intent.</p>

<p>The user is no longer making discrete requests. They’re delegating to the agent, which means the agent’s capabilities become the effective scope of what that user can do. And if those capabilities aren’t scoped to the user’s permissions, the agent can do far more than the user should be allowed to.</p>

<h3 id="33-the-delegation-problem">3.3 The delegation problem</h3>

<p>Here is the crux of it. When a user talks to an agent, they are delegating — handing off the execution of their intent to a system that will act on their behalf. The question is: with whose permissions?</p>

<p>In most implementations, the answer is the agent’s own. The agent runs with a service account, API keys, or a token provisioned by the developer. When it calls a tool, it authenticates with those credentials — not the user’s. The tool has no inherent knowledge of who initiated the conversation.</p>

<p>This creates a direct mismatch. The user might be an employee who isn’t allowed to approve expenses. But if the agent has credentials that permit <code class="language-plaintext highlighter-rouge">approve_expense</code>, and the user asks it to handle last week’s pending approvals, the agent will do it — successfully.</p>

<pre><code class="language-mermaid">graph TD
    U([Employee User]) --&gt;|"Can you handle last week's expenses?"| Agent
    Agent --&gt;|"invokes"| T[approve_expense]
    U -. "not authorized to approve expenses" .-&gt; T
</code></pre>

<p>The user didn’t do anything wrong. The agent didn’t malfunction. The system worked exactly as designed — and that is the problem.</p>

<p>Solving this requires making the user’s identity and permissions a first-class part of the agent’s execution context. The agent needs to know not just what tools exist, but which tools are available for the specific user it’s serving right now. That’s what tool-level authorization is about — and as we’ve seen in traditional applications, the industry has been solving this class of problem for a long time.</p>

<h2 id="4-this-problem-is-not-new">4. This problem is not new</h2>

<h3 id="41-a-brief-history">4.1 A brief history</h3>

<p>Authorization isn’t a new problem. Long before AI agents existed, engineers were wrestling with the same fundamental question: who should be able to do what, and how do you enforce it at scale?</p>

<p>The earliest solutions were access control lists — explicit tables mapping users to permissions. They worked for small systems but became unmanageable fast. In the 1990s, Role-Based Access Control (RBAC) emerged as a cleaner answer: instead of assigning permissions directly to users, you assign them to roles, and users inherit permissions through their role. More auditable, easier to reason about, and it scaled.</p>

<p>But RBAC had limits. It couldn’t express conditional access — things like “managers can approve expenses, but only under $1,000.” Attribute-Based Access Control (ABAC) addressed this by factoring in context: who the user is, what resource they’re accessing, and the circumstances under which the request is being made.</p>

<p>More recently, Relationship-Based Access Control (ReBAC) emerged for collaborative, graph-structured access patterns — “this user owns that document, and owners can share with editors.” Google published the Zanzibar paper in 2019, describing the system that powers authorization across Drive, YouTube, and other products, and the model has since influenced a generation of authorization systems.</p>

<p>Each model was built to solve the problems the previous one couldn’t handle. Together, they form a toolkit that agents can draw directly from — without reinventing anything.</p>

<h3 id="42-the-key-reframe">4.2 The key reframe</h3>

<p>The mental model that makes all of this tractable is simple.</p>

<p>Traditional access control asks: can this <strong>subject</strong> perform this <strong>action</strong> on this <strong>resource</strong>? Can Alice read this document? Can Bob delete this record? Can this service write to this bucket?</p>

<p>For agent tools, the same structure applies directly:</p>

<ul>
  <li><strong>Subject</strong>: the user talking to the agent</li>
  <li><strong>Action</strong>: invoking the tool</li>
  <li><strong>Resource</strong>: the tool itself</li>
</ul>

<p>Can this employee invoke <code class="language-plaintext highlighter-rouge">approve_expense</code>? Can this manager invoke <code class="language-plaintext highlighter-rouge">export_report</code>? Can this admin invoke <code class="language-plaintext highlighter-rouge">manage_users</code>?</p>

<p>But tool invocations don’t happen in a vacuum — they come with parameters. And parameters matter. A manager might be authorized to invoke <code class="language-plaintext highlighter-rouge">approve_expense</code>, but only when the <code class="language-plaintext highlighter-rouge">amount</code> is below a certain threshold. The tool is permitted; certain invocations of it are not. So the authorization question has two layers:</p>

<ol>
  <li>Can this user invoke this tool at all?</li>
  <li>With these specific parameters, is this particular invocation allowed?</li>
</ol>

<p>Tools are resources. Invocations are actions. Parameters are part of the context. The user is the subject. Once you see it that way, the models in the next section apply directly — no new mental model required.</p>

<h3 id="43-a-map-of-the-models">4.3 A map of the models</h3>

<p>The models we’ll cover next all answer the same subject/action/resource question, but from different angles — and each is better suited to certain kinds of problems.</p>

<ul>
  <li><strong>Role-based (RBAC)</strong>: users are assigned roles, roles carry specific permissions. The most widely used model and the right starting point for most agent tool authorization.</li>
  <li><strong>Attribute-based (ABAC)</strong>: decisions factor in attributes of the user, the resource, and the environment. Expressive enough to handle conditions like amount thresholds or time windows — things RBAC can’t express.</li>
  <li><strong>Relationship-based (ReBAC)</strong>: authorization derives from a graph of relationships between entities. Well-suited for delegation and resource-specific access patterns.</li>
</ul>

<pre><code class="language-mermaid">graph TD
    R[RBAC]
    A[ABAC]
    Re[ReBAC]
    H([Hybrid])

    R --&gt;|"+ context"| A
    R --&gt;|"+ relationships"| Re
    A --&gt; H
    Re --&gt; H
</code></pre>

<p>These models aren’t used in isolation — they operate at different points in the flow. RBAC is what you query at instantiation time to determine the agent’s tool set. ABAC and ReBAC come in at invocation time, enforcing fine-grained conditions on how those tools are used. We’ll see how they work together in the hybrid section. For now, let’s go through each model in turn.</p>

<h2 id="5-the-authorization-models">5. The authorization models</h2>

<h3 id="51-role-based-rbac">5.1 Role-based (RBAC)</h3>

<p>In RBAC, permissions are attached to roles, and users are assigned to roles. A role is an abstract description of a job function — what someone in that position is responsible for doing. It exists independently of any specific user.</p>

<p>This separation matters. You define what the <code class="language-plaintext highlighter-rouge">Manager</code> role can do once, as a standalone policy. Then separately, you assign users to that role. Updating the role’s permissions — adding a new tool, removing an old one — doesn’t require touching user assignments. And assigning a new manager to the system doesn’t require duplicating any policy logic. The two concerns are cleanly decoupled.</p>

<p>Roles also support hierarchy. Rather than granting a manager access to employee tools by assigning them to multiple buckets, you define that <code class="language-plaintext highlighter-rouge">Manager</code> inherits everything <code class="language-plaintext highlighter-rouge">Employee</code> can do and adds its own permissions on top:</p>

<pre><code class="language-mermaid">graph TD
    subgraph "Role definitions"
        AR[Admin] --&gt;|inherits| MR[Manager]
        MR --&gt;|inherits| ER[Employee]
    end

    Alice([Alice]) --&gt; ER
    Bob([Bob]) --&gt; MR
    Carol([Carol]) --&gt; AR

    ER --- sd[search_docs]
    ER --- ct[create_ticket]
    ER --- sn[send_notification]
    MR --- ae[approve_expense]
    MR --- vf[view_financials]
    MR --- er[export_report]
    AR --- qd[query_database]
    AR --- mu[manage_users]
</code></pre>

<p>Each role defines only its own permissions. The inherited ones come from the roles below it. A Manager can invoke <code class="language-plaintext highlighter-rouge">approve_expense</code> because the Manager role grants it — and also <code class="language-plaintext highlighter-rouge">search_docs</code> because the Employee role grants it, and Manager inherits from Employee. Carol, as an Admin, gets everything.</p>

<p>For agent tool authorization, RBAC maps cleanly. You define a role per user type, assign tools to each role, enforce role hierarchy, and at agent instantiation time you query which tools the user’s role permits. The agent is built with exactly that set.</p>

<p>RBAC is the right starting point for most systems. The policies are readable, the model is easy to audit — you can always answer “what can a Manager do?” with a direct lookup — and it covers the majority of tool authorization cases.</p>

<p>Where it runs into a wall is conditions. “Managers can invoke <code class="language-plaintext highlighter-rouge">approve_expense</code>” is expressible. “Managers can invoke <code class="language-plaintext highlighter-rouge">approve_expense</code>, but only when the <code class="language-plaintext highlighter-rouge">amount</code> parameter is below $1,000” is not — not in pure RBAC. Some teams try to work around this by creating more granular roles: <code class="language-plaintext highlighter-rouge">junior_manager</code>, <code class="language-plaintext highlighter-rouge">senior_manager</code>, each with different tool sets. But that path leads to role explosion: a proliferating set of roles that becomes hard to manage and harder to reason about.</p>

<p>When you start needing conditions, that’s the signal to reach for the next model.</p>

<h3 id="52-attribute-based-abac">5.2 Attribute-based (ABAC)</h3>

<p>RBAC answers the question “what role does this user have?” ABAC asks something richer: given everything we know about this user, this tool, these parameters, and the current context — should this invocation be allowed?</p>

<p>Where RBAC expresses permission as membership, ABAC expresses it as a policy evaluated against attributes across multiple dimensions:</p>

<ul>
  <li><strong>User attributes</strong>: role, department, clearance level, subscription tier</li>
  <li><strong>Tool and parameter attributes</strong>: which tool is being invoked, what values are being passed</li>
  <li><strong>Environment attributes</strong>: time of day, network location, session context</li>
</ul>

<pre><code class="language-mermaid">graph TD
    UA["User attributes&lt;br/&gt;role: Manager&lt;br/&gt;department: Finance"] --&gt; D{Policy evaluation}
    PA["Parameter attributes&lt;br/&gt;tool: approve_expense&lt;br/&gt;amount: $800"] --&gt; D
    EA["Environment attributes&lt;br/&gt;time: 10:30 AM&lt;br/&gt;day: Tuesday"] --&gt; D
    D --&gt;|allow| Allow[Invoke tool]
    D --&gt;|deny| Deny[Blocked]
</code></pre>

<p>This is what lets ABAC express the condition RBAC couldn’t. “Managers can approve expenses, but only under $1,000” becomes a policy rule evaluated at invocation time:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>allow if:
  user.role == "Manager"
  AND tool == "approve_expense"
  AND params.amount &lt; 1000
</code></pre></div></div>

<p>For the ops assistant, ABAC unlocks a range of controls that RBAC alone can’t handle:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">approve_expense</code> is available to managers, but only for amounts within their approval limit</li>
  <li><code class="language-plaintext highlighter-rouge">query_database</code> is available to admins, but only during business hours</li>
  <li><code class="language-plaintext highlighter-rouge">export_report</code> is available to managers in the finance department, not operations</li>
</ul>

<p>Each of these depends on context — and context is exactly what ABAC is designed to evaluate.</p>

<p>ABAC also addresses the second layer from section 4.2: not just “can this user invoke this tool?” but “can this user invoke this tool <em>with these parameters</em>?” That distinction matters as soon as any of your tools take inputs that carry their own sensitivity.</p>

<p>The trade-off is auditability. With RBAC, “what can a Manager do?” is a direct lookup. With ABAC, that same question requires evaluating every policy rule against every possible combination of attribute values — which is computationally and conceptually harder. ABAC policies can also grow complex over time: a rule that starts simple can accumulate conditions until it’s difficult to inspect, test, or explain to an auditor.</p>

<p>A common pattern is to layer ABAC on top of RBAC: roles establish which tools are in scope for a given user, and attribute checks refine which specific invocations are permitted within that scope. But this isn’t a universal prescription — simpler systems often use RBAC alone, and as we’ll see, some access patterns are better expressed with an entirely different model.</p>

<h3 id="53-relationship-based-rebac">5.3 Relationship-based (ReBAC)</h3>

<p>RBAC asks: what role does this user have? ABAC asks: what are the attributes of this user, this tool, and this context? ReBAC asks a different question entirely: what is the relationship between this user and this specific resource?</p>

<p>The distinction matters more than it might seem. Consider the <code class="language-plaintext highlighter-rouge">approve_expense</code> tool. Under RBAC, the policy is: “Managers can approve expenses” — meaning any manager can approve any expense. Under ReBAC, the policy becomes: “A user can approve expenses submitted by people they directly manage.” The tool is the same. The role is the same. But the authorization is now tied to a specific relationship in the organizational graph.</p>

<pre><code class="language-mermaid">graph LR
    Alice(["Alice&lt;br/&gt;Manager"]) --&gt;|manages| Bob(["Bob&lt;br/&gt;Employee"])
    Bob --&gt;|submitted| E42[Expense #42]
    Alice -.-&gt;|"can approve"| E42

    Carol(["Carol&lt;br/&gt;Manager"]) --&gt;|manages| Dave(["Dave&lt;br/&gt;Employee"])
    Dave --&gt;|submitted| E99[Expense #99]
    Carol -.-&gt;|"can approve"| E99

    Alice -. "cannot approve" .-&gt; E99
</code></pre>

<p>Alice manages Bob, who submitted Expense #42 — so Alice can approve it. Carol manages Dave, who submitted Expense #99 — so Carol can approve it. Alice cannot approve Expense #99, because she has no management relationship to Dave, even though they share the same role.</p>

<p>This is something neither RBAC nor ABAC can express cleanly. RBAC doesn’t know about the specific relationship between Alice and Bob. ABAC could approximate it with a <code class="language-plaintext highlighter-rouge">direct_report_ids</code> attribute on the user, but that attribute would need to be kept in sync with the org structure and injected into every authorization decision — fragile and hard to maintain. ReBAC makes the relationship itself the authorization primitive.</p>

<p>Authorization decisions in ReBAC work by checking whether a path exists in the relationship graph from the user to the resource through a chain of authorized relationship types. “Can Alice approve Expense #42?” becomes: does a path exist from Alice to Expense #42 through <code class="language-plaintext highlighter-rouge">manages → submitted</code>? If yes, allow. If no, deny.</p>

<p>One property this enables that RBAC and ABAC cannot match: <strong>atomic revocation</strong>. When Bob moves to a different team and the <code class="language-plaintext highlighter-rouge">manages</code> relationship between Alice and Bob is removed, Alice immediately loses the ability to approve Bob’s future expenses. There’s no role to update, no attribute to recalculate, no permission to explicitly revoke. Removing the relationship is sufficient — all access derived from it disappears in the same operation.</p>

<p>The trade-off is relationship management overhead. Every authorization-relevant relationship between users and resources needs to be stored and kept current. In an ops assistant with a small team, this is straightforward. At the scale of Google Drive — billions of documents, millions of users, continuous changes — it requires specialized infrastructure, which is exactly what Zanzibar was built to provide.</p>

<p>For most agent tool authorization scenarios, ReBAC is the right model when your access decisions are fundamentally about who has what relationship to whom or to what — approval hierarchies, team ownership, resource-specific delegation. If your authorization logic says “any manager can do X,” that’s RBAC. If it says “this manager can do X for these specific resources because of how they’re related to them,” that’s ReBAC.</p>

<h3 id="54-hybrid-models">5.4 Hybrid models</h3>

<p>Before going further, there is one principle that sits above all model choices: an agent must never have access to a tool it shouldn’t call. This isn’t a preference — it’s a security requirement. The tool set the agent is instantiated with must be determined by deterministic code that reads the user’s permissions from the authorization layer. Not approximated. Not filtered by the agent after the fact. Defined upfront, before the agent starts.</p>

<p>With that established, the three models we’ve covered aren’t alternatives — they address different aspects of the same authorization problem, and they operate at different points in the agent’s lifecycle.</p>

<p><strong>RBAC answers the instantiation question</strong>: which tools does this user get at all? This is evaluated before the agent starts. An employee’s agent is provisioned with <code class="language-plaintext highlighter-rouge">search_docs</code>, <code class="language-plaintext highlighter-rouge">create_ticket</code>, and <code class="language-plaintext highlighter-rouge">send_notification</code>. It knows nothing about <code class="language-plaintext highlighter-rouge">approve_expense</code> — the tool is simply not there.</p>

<p><strong>ABAC answers the invocation question</strong>: given the tools the agent has, is this specific invocation permitted? A manager’s agent has <code class="language-plaintext highlighter-rouge">approve_expense</code>, but when the agent tries to call it with an <code class="language-plaintext highlighter-rouge">amount</code> of $1,500 — exceeding the approval limit — the ABAC check denies it.</p>

<p><strong>ReBAC answers the resource question</strong>: does the right relationship exist between this user and the specific resource being acted on? A manager’s agent invokes <code class="language-plaintext highlighter-rouge">approve_expense</code> within their limit, but for an expense submitted by someone outside their direct reports — the relationship check fails.</p>

<pre><code class="language-mermaid">flowchart LR
    subgraph "Agent instantiation"
        RBAC["RBAC&lt;br/&gt;Which tools does&lt;br/&gt;this user get?"] --&gt; Agent[[Agent with permitted tools]]
    end

    subgraph "Tool invocation"
        Agent --&gt; ABAC{"ABAC&lt;br/&gt;Are parameters&lt;br/&gt;and context valid?"}
        ABAC --&gt;|No| D1([Denied])
        ABAC --&gt;|Yes| ReBAC{"ReBAC&lt;br/&gt;Does the required&lt;br/&gt;relationship exist?"}
        ReBAC --&gt;|No| D2([Denied])
        ReBAC --&gt;|Yes| Allow([Tool invoked])
    end
</code></pre>

<p>The models handle what they’re each best suited for, at the right moment in the flow.</p>

<p>This isn’t a prescription to implement all three from the start. Many agent systems need only RBAC, and that’s the right call — don’t add complexity your requirements don’t justify. Attribute conditions are the most common next need, and ABAC extends naturally when they appear. ReBAC enters the picture when authorization depends on specific relationships between users and resources that are meaningful enough to model explicitly.</p>

<p>Think of the models as tools you reach for as your requirements grow, not a stack to implement all at once. Section 7 has a more concrete guide for choosing where to start.</p>

<h2 id="6-where-enforcement-happens">6. Where enforcement happens</h2>

<h3 id="60-the-two-enforcement-points">6.0 The two enforcement points</h3>

<p>Understanding the authorization models is one thing. Knowing where to enforce them in the actual architecture is another.</p>

<p>There are two enforcement points, and they serve different purposes. The first is mandatory. The second is complementary and applies in specific cases.</p>

<p><strong>Enforcement point 1: agent instantiation (mandatory)</strong></p>

<p>Before the agent starts, something in your architecture must determine which tools this user is permitted to invoke, and the agent must be built with exactly that set. The mechanism varies: it might be a JWT containing permission scopes, a role lookup against your authorization system, or OAuth token claims that indicate which capabilities the user has. The specific approach depends on your architecture. What doesn’t vary is the requirement: the agent’s tool set must reflect the user’s permissions before the agent starts reasoning.</p>

<pre><code class="language-mermaid">flowchart LR
    U([User]) --&gt;|identity + permissions| Component["Authorization Component"]
    Component --&gt;|"permitted tools for this user"| Factory[Agent Factory]
    Factory --&gt;|"instantiates with permitted tools"| Agent[[Agent]]
</code></pre>

<p>This is non-negotiable. Giving the agent all tools and hoping it won’t use the ones it shouldn’t is not a security control. The agent will reason about every tool it has, and it can be manipulated through prompt injection into using tools it knows about. The only safe position is for unauthorized tools to not exist in the agent’s context at all.</p>

<p><strong>Enforcement point 2: somewhere in the invocation path (complementary)</strong></p>

<p>There are cases where two users have access to the same tool but with different permission scopes. Consider two managers: one with an approval limit of $1,000 and another with $10,000. Instantiation-time provisioning can’t distinguish between them — both get <code class="language-plaintext highlighter-rouge">approve_expense</code>. What differs is how they’re allowed to invoke it.</p>

<p>In these cases, a parameter-level authorization check needs to happen somewhere in the invocation path. Where exactly depends on your architecture:</p>

<ul>
  <li><strong>Inside the tool</strong>: the tool reads user context from agent state and checks permissions before executing. Most agentic frameworks support agent state — a context object accessible inside tool calls — where user identity and attributes can be injected at startup.</li>
  <li><strong>In an external API</strong>: the tool calls an API that enforces its own authorization. The API validates the request against the user’s permissions and rejects it if it’s out of scope. In this case, the tool itself doesn’t need to implement any authorization logic.</li>
  <li><strong>In a middleware layer</strong>: a gateway or interceptor sits between the agent and the tool, evaluating the invocation against policy before allowing it through.</li>
</ul>

<pre><code class="language-mermaid">flowchart LR
    Agent[[Agent]] --&gt;|"invokes tool with parameters"| Tool[Tool]
    Tool --&gt;|"calls"| API["External API / Middleware"]
    API --&gt;|"enforces authorization"| Check{Allowed?}
    Check --&gt;|yes| Action[Execute]
    Check --&gt;|no| Reject[Rejected]
</code></pre>

<p>The important thing is that it happens somewhere before the action executes. The architecture is flexible; the requirement is not.</p>

<p><strong>The two points work together.</strong> Instantiation-time provisioning ensures the agent can’t reach tools it shouldn’t have. Invocation-time enforcement ensures it can’t misuse the tools it does have. The sections that follow cover the specific mechanisms — OAuth scopes, middleware, and policy engines — that you can use to implement each point.</p>

<h3 id="61-oauth-scopes">6.1 OAuth scopes</h3>

<p>OAuth scopes are the most familiar authorization primitive for developers building applications that call external APIs. When a user authenticates, they consent to a set of scopes — a declaration of what the application is allowed to do on their behalf. The authorization server encodes those scopes into an access token, and every downstream API call is gated by whether the required scope is present.</p>

<p>For agent tools that call external services — an expense platform, a CRM, a data warehouse — scopes are a natural and standards-compliant access layer. If the token doesn’t carry the <code class="language-plaintext highlighter-rouge">expenses:write</code> scope, the expense API rejects the call. That check happens at the API level, without any extra logic in your agent.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant User
    participant Agent
    participant Auth as Auth Server
    participant API as External API

    User-&gt;&gt;Auth: Authenticate
    Auth--&gt;&gt;User: Access token (scopes: expenses:read, expenses:write)
    User-&gt;&gt;Agent: Start session (token passed through)
    Agent-&gt;&gt;API: Call approve_expense (with token)
    API-&gt;&gt;API: Scope present?
    API--&gt;&gt;Agent: Allowed / Rejected
</code></pre>

<p>If you map each tool to a scope, scopes can also drive instantiation-time provisioning. At startup, read the scopes from the user’s token, and build the agent with only the tools those scopes permit. For simpler systems — where the authorization question is purely “does this user have access to this tool at all?” with no parameter-level conditions — this approach can be sufficient on its own.</p>

<p>Where scopes run into limits is when fine-grained conditions enter the picture. Scopes are coarse — they gate access to a tool or operation category, but they can’t express rules like “this user can approve expenses up to $1,000” or “this user can only export reports for their own department.” Tokens are also static after issuance: the scopes encoded at login time can’t change mid-session, and revoking a scope requires token expiry or active introspection.</p>

<p>For systems where tool-level gating is enough, scopes are a clean and low-overhead solution. When you need parameter-level enforcement on top of that, scopes handle the outer layer and you add a complementary mechanism for the rest.</p>

<h3 id="62-pre-dispatch-middleware">6.2 Pre-dispatch middleware</h3>

<p>A middleware gate is a layer of code that sits between the agent’s decision to invoke a tool and the tool’s actual execution. The agent calls a central dispatcher; the dispatcher reads user context, evaluates permission rules, and either forwards the call to the tool or rejects it.</p>

<pre><code class="language-mermaid">flowchart LR
    Agent[[Agent]] --&gt;|"invoke tool(params)"| MW[Middleware Gate]
    MW --&gt;|reads| State["Agent State&lt;br/&gt;user context"]
    MW --&gt;|allow| Tool[Tool]
    MW --&gt;|deny| Reject([Rejected])
    Tool --&gt; Action[Execute]
</code></pre>

<p>The middleware can serve both enforcement points. At instantiation time, it can filter the list of available tools based on the user’s role before passing them to the agent. At invocation time, it evaluates parameter-level conditions — approval limits, department checks, time windows — before the tool runs.</p>

<p>A simple implementation looks something like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>function dispatch(tool_name, params, user_context):
    if tool_name not in permitted_tools(user_context.role):
        raise AuthorizationError("tool not permitted")
    if not check_conditions(user_context, tool_name, params):
        raise AuthorizationError("invocation not permitted")
    return tools[tool_name](params)
</code></pre></div></div>

<p>This approach has a lot going for it early on. It requires no external dependencies, keeps authorization logic in one place, and gives you full control over how rules are evaluated. For smaller systems or early-stage products where the permission model is still evolving, it’s often the right place to start.</p>

<p>The friction appears as the rule set grows. Authorization logic encoded in application code must be redeployed every time a rule changes. More importantly, it tends to drift — conditions accumulate, edge cases get added inline, and what started as a clean central gate becomes a tangle of conditionals spread across the codebase. At that point, the policy is hard to inspect, hard to test independently, and hard to hand to an auditor.</p>

<p>When you find yourself wanting to manage authorization logic separately from application code — version it, test it in isolation, update it without a deploy — that’s the signal to consider moving to a policy engine.</p>

<h3 id="63-policy-engines">6.3 Policy engines</h3>

<p>A policy engine externalizes authorization logic into declarative policies that live outside your application code. Instead of embedding permission rules in a dispatcher or a tool, the application asks the policy engine a question — “is this user allowed to invoke this tool with these parameters?” — and the engine evaluates the current policy and returns a decision.</p>

<pre><code class="language-mermaid">flowchart LR
    subgraph "Instantiation"
        F[Agent Factory] --&gt;|"which tools for this user?"| PE[(Policy Engine)]
        PE --&gt;|permitted tool list| F
    end

    subgraph "Invocation"
        A[[Agent]] --&gt;|"is this invocation allowed?"| PE
        PE --&gt;|allow / deny| A
    end
</code></pre>

<p>The policy itself is written in a declarative language and stored separately from the application — in a file, a repository, or the engine’s own storage. A rule that governs <code class="language-plaintext highlighter-rouge">approve_expense</code> might look like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>allow if:
    input.user.role == "manager"
    input.params.amount &lt;= input.user.approval_limit
    input.time.hour &gt;= 9
    input.time.hour &lt;= 17
</code></pre></div></div>

<p>This is the same logic that would otherwise live in application code — but now it’s in a policy file that can be read, reviewed, and updated independently. Change the approval limit threshold, adjust the time window, add a department condition: the policy changes, the application doesn’t.</p>

<p>This separation is what makes policy engines valuable in regulated or audited environments. The authorization rules are inspectable as a standalone artifact. They can be version-controlled alongside the rest of your codebase, tested in isolation, and handed to a compliance team without requiring them to navigate application logic.</p>

<p>Tools like OPA (Open Policy Agent), Cedar, and Cerbos are common choices, each with their own policy language and evaluation model. They differ in expressiveness, performance characteristics, and how well they handle ABAC versus ReBAC patterns — but the architectural pattern is the same: your application becomes a policy decision client, and the engine is the policy decision point.</p>

<p>The trade-off is operational overhead. A policy engine is an additional component to deploy, operate, and integrate. Policy languages have a learning curve, and debugging a failed authorization decision in a declarative language is a different skill than debugging application code. For small systems with simple, stable rules, this overhead may not be justified. As rules grow more complex and more people — developers, security teams, auditors — need to reason about them, the investment pays off.</p>

<h2 id="7-choosing-your-approach">7. Choosing your approach</h2>

<h3 id="71-start-with-the-right-question">7.1 Start with the right question</h3>

<p>Before reaching for a model or a mechanism, it’s worth spending a moment on what your authorization requirements actually are. The right starting point varies significantly depending on the nature of your access control problem, and the wrong choice creates friction that compounds over time.</p>

<p>Two questions cut through most of the decision:</p>

<p><strong>What determines whether a user can invoke a tool?</strong></p>

<p>If the answer is purely their role or user type — “managers can approve expenses, employees cannot” — RBAC is likely sufficient. If the answer involves conditions on the invocation itself — “managers can approve expenses, but only under their approval limit” — you need ABAC on top. If the answer involves the specific relationship between the user and the resource being acted on — “managers can approve expenses submitted by their direct reports” — ReBAC is the right fit.</p>

<p><strong>How stable and auditable do your rules need to be?</strong></p>

<p>If your rules are small in number, unlikely to change frequently, and don’t need to be reviewed by anyone outside your team, middleware is a reasonable place to start. If your rules need to be updated independently of application deployments, inspectable by a compliance or security team, or testable in isolation, a policy engine is the better long-term foundation.</p>

<p>These two questions map to the two enforcement points from section 6: the first shapes how you build the agent’s tool set at instantiation; the second shapes how you implement invocation-time checks. Answer them separately, because the right choice for each doesn’t have to be the same.</p>

<h3 id="72-a-decision-map">7.2 A decision map</h3>

<p><strong>Choosing your authorization model:</strong></p>

<pre><code class="language-mermaid">flowchart TD
    Q1{"Does tool access depend&lt;br/&gt;on user type or role?"}

    Q1 --&gt;|Yes| RBAC[Start with RBAC]
    Q1 --&gt;|"No — relationship-based"| ReBAC

    RBAC --&gt; Q2{"Do invocations need&lt;br/&gt;conditions beyond role?"}
    Q2 --&gt;|No| UseRBAC([RBAC])
    Q2 --&gt;|Yes| Q3{"Resource-specific&lt;br/&gt;relationship checks?"}
    Q3 --&gt;|No| UseABAC([RBAC + ABAC])
    Q3 --&gt;|Yes| UseHybrid([RBAC + ABAC + ReBAC])

    ReBAC --&gt; Q4{"Conditions on&lt;br/&gt;invocations?"}
    Q4 --&gt;|No| UseReBAC([ReBAC])
    Q4 --&gt;|Yes| UseHybrid2([ReBAC + ABAC])
</code></pre>

<p><strong>Choosing your enforcement mechanism:</strong></p>

<pre><code class="language-mermaid">flowchart TD
    M1{"Tools call external APIs&lt;br/&gt;that enforce OAuth?"}
    M1 --&gt;|"Yes — tool-level gating is sufficient"| Scopes([OAuth Scopes])
    M1 --&gt;|Need more control| M2{"Rules simple, stable,&lt;br/&gt;and team-internal?"}
    M2 --&gt;|Yes| MW([Middleware])
    M2 --&gt;|"No — need auditability or compliance review"| PE([Policy Engine])
</code></pre>

<p>The two maps are independent. You might use RBAC + ABAC for your authorization model and middleware as your enforcement mechanism. Or ReBAC with a policy engine. The model choice is about the logic of your authorization rules; the mechanism choice is about where and how you evaluate them.</p>

<h3 id="73-design-for-growth">7.3 Design for growth</h3>

<p>The most common mistake isn’t picking the wrong model — it’s embedding authorization logic directly in individual tools, scattered across the codebase, with no central enforcement point. Once you’re there, you can’t answer basic questions like “what can a manager do?” without reading every tool’s implementation. Every new tool requires manually adding the same checks. Auditing becomes archaeology.</p>

<p>The central enforcement point is the first thing to get right. Whether it’s an OAuth scope check, a middleware gate, or a policy engine matters less than having a single, deliberate place where authorization decisions are made. Everything else can be improved incrementally.</p>

<p>From there, the signals for evolving are usually clear:</p>

<ul>
  <li><strong>Role explosion</strong>: you keep creating new roles to handle edge cases. That’s the signal for ABAC — conditions should be expressed in policies, not as role variants.</li>
  <li><strong>Rules drifting into tools</strong>: authorization logic has started accumulating across individual tool implementations rather than staying centralized. Time to establish or reinforce the middleware gate.</li>
  <li><strong>Audit requests</strong>: someone asks you to prove which users can do what, or compliance requires an independent review of your authorization rules. If those rules live in application code, that conversation is painful. A policy engine makes it tractable.</li>
  <li><strong>Relationship-dependent access</strong>: you find yourself maintaining lists of “authorized users per resource” and keeping them in sync by hand. That’s the shape of a ReBAC problem.</li>
</ul>

<p>Start with what your current requirements justify. Add complexity only when the signals are clear. And from the beginning, hold the one principle this post keeps returning to: the agent’s tool set is determined by your authorization layer, not by the agent’s own reasoning. That boundary is the foundation everything else is built on.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[AI agents have tools. Which tools any given user should be able to invoke — and under what conditions — is an authorization problem. It’s one many agent developers don’t encounter until they’re deep in implementation, and one the industry has been solving for decades in traditional software.]]></summary></entry><entry><title type="html">OAuth 2.0 from the AI Engineer’s Perspective</title><link href="https://maledias.github.io/2026/02/28/oauth-2-from-the-ai-engineers-perspective.html" rel="alternate" type="text/html" title="OAuth 2.0 from the AI Engineer’s Perspective" /><published>2026-02-28T00:00:00+00:00</published><updated>2026-02-28T00:00:00+00:00</updated><id>https://maledias.github.io/2026/02/28/oauth-2-from-the-ai-engineers-perspective</id><content type="html" xml:base="https://maledias.github.io/2026/02/28/oauth-2-from-the-ai-engineers-perspective.html"><![CDATA[<p>This post is a practical guide to OAuth 2.0 for AI engineers.
It covers the core concepts — roles, scopes, access tokens, and JWTs — and then goes deep on the grant types most relevant to AI and agentic systems: Authorization Code, Authorization Code with PKCE, Client Credentials, Refresh Token, Device Authorization, and Token Exchange. Along the way, it maps each grant type to real agentic scenarios, explains how OAuth 2.0 relates to OpenID Connect, and gives you a decision framework for choosing the right grant type in your own systems.</p>

<h1 id="oauth-20-from-the-ai-engineer-perspective">OAuth 2.0 from the AI Engineer Perspective</h1>

<h2 id="introduction">Introduction</h2>

<p>Imagine you’re building an AI-powered secretary — a SaaS application that joins your users’ calls with their clients and, based on what was discussed, schedules follow-up meetings in their Google Calendar. To do that, your application needs permission to access each user’s calendar. Your users probably won’t be thrilled about sharing their Google account passwords with your application, no matter how honest your intentions are. And even if they were, you’d want your application to only be able to manage calendar events, not access everything else in their Google account. That permission should also be revocable at any time. How do you design that system?</p>

<p>This is exactly the problem OAuth 2.0 was built to solve. OAuth 2.0 is the industry standard protocol for authorization. It defines a set of rules and workflows — called grant types — that cover scenarios such as a client application obtaining delegated permission to access user-owned resources on behalf of that user, without ever handling their credentials, or a client application accessing its own resources directly. Each grant type is designed for a specific scenario: a user authorizing a web app, a backend service calling another service, a CLI tool running on a device without a browser, or an agent delegating access to another agent.</p>

<p>As an AI engineer, you’ll encounter these scenarios constantly. An agent accessing user data, a pipeline of agents where one needs to act on behalf of another, a background service making API calls without any user present — each of these requires a different authorization approach, and picking the wrong one leads to systems that are either insecure, brittle, or both. In this post, we’ll cover the OAuth 2.0 grant types most relevant to AI engineering: Authorization Code, Authorization Code with PKCE, Client Credentials, Refresh Token, Device Authorization, and Token Exchange. Along the way, we’ll look at how each one maps to real agentic scenarios.</p>

<p>Authorization is one of those things that’s easy to get approximately right and hard to get exactly right. As AI agents become more autonomous, longer-lived, and more deeply integrated with sensitive systems, the cost of getting it wrong compounds. A poorly scoped token, a credential stored in the wrong place, or the wrong grant type chosen for the wrong scenario can quietly become a serious vulnerability. The goal of this post is not just to explain how OAuth 2.0 works, but to give you the mental model to design authorization into your AI systems deliberately — from the start.</p>

<h2 id="oauth-20-roles">OAuth 2.0 Roles</h2>

<p>OAuth 2.0 defines four roles that together describe who owns what, who wants access, and who mediates the whole interaction. Let’s ground them in the AI secretary scenario from earlier.</p>

<p>The <strong>Resource Owner</strong> is the entity that owns the protected resource and can grant access to it. In the secretary example, that’s your user — the person whose Google Calendar holds their meetings.</p>

<p>The <strong>Client</strong> is the application that wants access to the protected resource, acting on behalf of the Resource Owner with their authorization. In the secretary example, that’s your SaaS application. In an agentic system, the agent itself often plays this role: it’s the party making API calls and requesting permission to act.</p>

<p>The <strong>Authorization Server</strong> is what mediates trust. It authenticates the Resource Owner, obtains their consent, and issues access tokens to the Client. In the secretary example, that’s Google’s OAuth infrastructure — the system behind the consent screen your users see when they connect their calendar.</p>

<p>The <strong>Resource Server</strong> is where the protected resources live. It accepts requests from the Client, validates the access token, and either serves the resource or rejects the request. In the secretary example, that’s the Google Calendar API. We’ll look at exactly how that enforcement works once we cover tokens.</p>

<p>These roles can feel abstract in isolation, so it’s worth pausing on a few things that commonly cause confusion.</p>

<p>First, the Authorization Server and Resource Server are logically distinct roles, but they don’t have to be run by different systems. In many implementations — including Google’s — they’re operated by the same provider. What matters is the separation of concerns: the Authorization Server decides whether the Client is allowed to access something; the Resource Server enforces that decision at request time.</p>

<p>Second, and more importantly for AI engineering: the same entity can play different roles depending on which resource access you’re looking at. Consider the AI secretary again. When it accesses a user’s Google Calendar, the agent is the Client and the user is the Resource Owner — the agent is acting on someone else’s behalf. But suppose that same agent also pulls in weather forecasts to help schedule outdoor meetings. The weather API doesn’t belong to any user; the agent is accessing it on its own behalf, not delegating from anyone. That’s a different kind of interaction entirely — different Resource Server, different Authorization Server, different role configuration.</p>

<p>This is the normal state of an agentic application. An agent typically has many resource access interactions happening across its lifetime, and each one has its own set of roles. One interaction might involve user-delegated access to a calendar. Another might involve the agent accessing a third-party data API directly. A third might involve one agent calling another. Each of these is a separate OAuth interaction with its own Client, Resource Owner, Authorization Server, and Resource Server — and the same entity can appear in different roles across different interactions. Keeping this in mind will make the grant types that follow much easier to reason about: each grant type is really a description of how one particular interaction is authorized, not how an entire system works.</p>

<h2 id="scopes">Scopes</h2>

<p>When your AI secretary requests access to a user’s Google Calendar, it doesn’t just ask for “access to Google.” It asks for access to something specific — in Google’s case, something like <code class="language-plaintext highlighter-rouge">https://www.googleapis.com/auth/calendar.events</code>, which grants permission to read and write calendar events. That string is a scope, and it defines the boundaries of what the token the Client receives will be permitted to do (more on tokens in the next section).</p>

<p>Scopes flow through the OAuth process in a predictable way. The Client declares which scopes it needs when it initiates the authorization request. The Authorization Server presents those scopes to the Resource Owner on a consent screen — this is why you see prompts like “This app wants to: view and edit your calendar events.” If the user consents, the resulting token is restricted to exactly those scopes.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant C as Client (AI Secretary)
    participant AS as Authorization Server (Google OAuth)
    participant RO as Resource Owner (User)
    participant RS as Resource Server (Calendar API)

    C-&gt;&gt;AS: Authorization request (scopes: calendar.events)
    AS-&gt;&gt;RO: Consent screen — "This app wants to: view and edit your calendar events"
    RO-&gt;&gt;AS: Approves
    AS-&gt;&gt;C: Token restricted to calendar.events
    C-&gt;&gt;RS: API request + token
    RS-&gt;&gt;C: Calendar data
</code></pre>

<p>That’s the user-delegated flow. But recall the weather API example from the previous section — the agent accessing forecast data on its own behalf, with no user involved. Scopes still apply here: the agent requests specific scopes when it authenticates with the Authorization Server, and the resulting token is similarly restricted to those scopes. The difference is that there’s no consent screen and no user approving anything at runtime. Instead, the scopes the application is allowed to request are pre-configured when the application is registered with the Authorization Server. The token the agent receives is still scoped — just scoped to what the application itself is authorized for, not what a user has delegated.</p>

<p>One thing worth knowing: OAuth doesn’t define what scope values should look like or what they mean. The specification only says they’re space-delimited strings. Every provider defines their own vocabulary. Google’s scopes are long URLs. GitHub’s look like <code class="language-plaintext highlighter-rouge">repo</code> or <code class="language-plaintext highlighter-rouge">read:user</code>. A custom internal API might use <code class="language-plaintext highlighter-rouge">reports:read</code> or <code class="language-plaintext highlighter-rouge">calendar:write</code>. There’s no universal scope language — when integrating with a new API, you’ll need to consult that provider’s documentation to understand what scopes exist and what each one covers.</p>

<p>For AI engineering, scopes are your primary tool for least-privilege access. An agent should request only the scopes it needs for the specific task it’s performing — not a broad set “just in case.” This matters more than it might seem. An agent with overly permissive access can do more damage if it’s compromised, behaves unexpectedly, or is manipulated by a malicious prompt. A narrowly scoped token limits the blast radius. If your AI secretary only needs to create calendar events, it should request <code class="language-plaintext highlighter-rouge">calendar.events</code> — not full calendar access, and certainly not access to the user’s email or drive. The same principle applies to the weather API: even though the agent is acting on its own behalf, it should still request only the scopes it actually needs.</p>

<p>It’s also worth noting that scopes declare what a token is permitted to do — but they don’t enforce it by themselves. Think of it like a driver’s license. The licensing authority — the Authorization Server — issues your license and specifies on it what you’re authorized to operate: a car, but not a bus. The license itself is the token, and it carries that permission with it wherever you go. But the licensing authority isn’t present every time you drive. When a police officer pulls you over at a checkpoint, they’re the Resource Server: they check your license, read what it says, and decide whether you’re in compliance. The licensing authority trusted you enough to issue the license; the officer enforces what it says in the real world. We’ll cover exactly how that enforcement works in practice when we get to tokens.</p>

<h2 id="access-tokens--jwt">Access Tokens &amp; JWT</h2>

<p>Every time the AI secretary makes a request to the Google Calendar API, it includes a credential in the HTTP request — an access token. The Resource Server reads that token to decide whether the request is authorized. But what exactly is an access token, and what does it contain?</p>

<p>The OAuth spec deliberately doesn’t mandate a format. A token is just a string the Client presents and the Resource Server validates. What matters is the validation model, and there are two main approaches.</p>

<p>The first produces what’s known as a <strong>by-reference token</strong> — a random, opaque string with no inherent meaning. The Resource Server can’t read anything from it directly; instead, it calls the Authorization Server to look up what permissions that string maps to. This works, but it means every API call requires a network round-trip to the Authorization Server.</p>

<p>The second produces a <strong>by-value token</strong> — a self-contained token that encodes all the information the Resource Server needs to validate it, signed so that the contents can be trusted without any external lookup. JWT (JSON Web Token) is the most widely used format for by-value tokens, and it’s what most OAuth implementations use in practice.</p>

<p>The diagrams below show what validation looks like for each approach:</p>

<p><strong>By-reference token (opaque)</strong></p>
<pre><code class="language-mermaid">sequenceDiagram
    participant C as Client
    participant RS as Resource Server
    participant AS as Authorization Server

    C-&gt;&gt;RS: API request + opaque token
    RS-&gt;&gt;AS: Introspection request — is this token valid?
    AS-&gt;&gt;RS: Token info (sub, scope, exp...)
    RS-&gt;&gt;RS: Check scope, apply app logic
    RS-&gt;&gt;C: Response
</code></pre>

<p><strong>By-value token (JWT)</strong></p>
<pre><code class="language-mermaid">sequenceDiagram
    participant C as Client
    participant RS as Resource Server

    note over RS: AS public key fetched once at startup and cached
    C-&gt;&gt;RS: API request + JWT
    RS-&gt;&gt;RS: Verify signature using cached AS public key
    RS-&gt;&gt;RS: Check claims (exp, aud, scope)
    RS-&gt;&gt;RS: Read sub, apply app logic
    RS-&gt;&gt;C: Response
</code></pre>

<p><strong>JWT structure</strong></p>

<p>A JWT is three Base64URL-encoded strings joined by dots: <code class="language-plaintext highlighter-rouge">header.payload.signature</code>.</p>

<p>The <strong>header</strong> specifies the token type and the signing algorithm — for example, RS256 (RSA with SHA-256).</p>

<p>The <strong>payload</strong> contains claims — statements about the token and the entity it represents. Some standard claims you’ll encounter regularly:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">sub</code> (subject): who the token represents, typically a user ID</li>
  <li><code class="language-plaintext highlighter-rouge">iss</code> (issuer): which Authorization Server issued the token</li>
  <li><code class="language-plaintext highlighter-rouge">exp</code> (expiration): a Unix timestamp after which the token is no longer valid</li>
  <li><code class="language-plaintext highlighter-rouge">aud</code> (audience): which Resource Server this token is intended for</li>
  <li><code class="language-plaintext highlighter-rouge">scope</code>: the permissions the token carries</li>
</ul>

<p>The <strong>signature</strong> is generated by the Authorization Server using its private key. When the Resource Server receives the token, it verifies the signature using the Authorization Server’s public key — typically fetched once from a well-known endpoint and cached locally. If verification passes, the Resource Server knows the token hasn’t been tampered with and came from a trusted source, without making any network call.</p>

<p>Here’s what a decoded JWT payload might look like in the AI secretary scenario:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"sub"</span><span class="p">:</span><span class="w"> </span><span class="s2">"user_8472"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"iss"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://accounts.google.com"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"aud"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://www.googleapis.com/"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"scope"</span><span class="p">:</span><span class="w"> </span><span class="s2">"calendar.events"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"exp"</span><span class="p">:</span><span class="w"> </span><span class="mi">1740000000</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>How the Resource Server actually enforces authorization</strong></p>

<p>Back in the Roles section we said the Resource Server “enforces the authorization decision at request time” and deferred the details. Here’s what that actually looks like in practice.</p>

<p>When the Calendar API receives a request, it validates the JWT signature, checks that the token hasn’t expired, confirms the <code class="language-plaintext highlighter-rouge">scope</code> covers the operation being requested, and then reads the <code class="language-plaintext highlighter-rouge">sub</code> claim — the user’s identifier — and uses it in application code to scope the data it returns, querying only calendar events that belong to that user. That last step is plain application logic, not OAuth. OAuth tells the Resource Server who the request is for and what it’s allowed to do; what to actually return is up to the application. This is why a narrowly scoped token can’t be redirected to access another user’s data — the <code class="language-plaintext highlighter-rouge">sub</code> claim pins it to a specific identity.</p>

<p><strong>The trade-off: revocation</strong></p>

<p>Because JWTs are self-contained and validated locally, the Authorization Server has no visibility into whether a token is being used after it’s issued. If a token is compromised — or a user explicitly revokes access — the token remains valid until its <code class="language-plaintext highlighter-rouge">exp</code> timestamp passes.</p>

<p>Revocation is technically possible: you can maintain a server-side blocklist of revoked token identifiers and check incoming tokens against it on every request. But this reintroduces the server-side lookup that by-value tokens were meant to avoid, and it’s not standardized in OAuth 2.0 — it requires custom coordination between the Authorization Server and Resource Server. The honest summary: it’s achievable, but the cost is high enough that most implementations either accept the gap or switch to shorter token lifetimes as a mitigation. (<a href="https://stackoverflow.com/questions/31919067/how-can-i-revoke-a-jwt-token">More on the trade-offs here.</a>)</p>

<p>For AI agents this matters more than it might seem. Agents can be long-running, operate autonomously, and hold tokens across many interactions. If an agent is compromised or starts behaving unexpectedly, you want to cut off its access immediately — and with JWTs, “immediately” has an asterisk. The practical answer is to keep token lifetimes short and rely on refresh tokens to maintain access over time, which the Refresh Token Grant handles — covered in the next section.</p>

<h2 id="grant-types">Grant Types</h2>

<p>Each grant type below follows the same structure: how it works, an AI scenario, and security notes.</p>

<h3 id="authorization-code-grant">Authorization Code Grant</h3>

<p>The Authorization Code Grant is the most widely used OAuth flow. It’s designed for scenarios where a user is present and needs to authorize a client application to access their resources — which is exactly what happens the first time one of your users connects their Google Calendar to the AI secretary.</p>

<p><strong>How it works</strong></p>

<p>The flow starts when the Client redirects the user’s browser to the Authorization Server, including the requested scopes and a redirect URI — the URL the Authorization Server should send the user back to after they approve. The user authenticates with the Authorization Server and sees the consent screen. If they approve, the Authorization Server redirects them back to the Client’s redirect URI with a short-lived authorization code in the URL. The Client then takes that code and makes a direct, server-to-server request to the Authorization Server’s token endpoint — authenticating itself with its client ID and secret — and exchanges the code for an access token.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant U as User (Browser)
    participant C as Client (AI Secretary)
    participant AS as Authorization Server
    participant RS as Resource Server (Calendar API)

    C-&gt;&gt;U: Redirect to Authorization Server with requested scopes
    U-&gt;&gt;AS: User authenticates and sees consent screen
    AS-&gt;&gt;U: Redirect back to Client with authorization code
    U-&gt;&gt;C: Authorization code delivered via redirect
    C-&gt;&gt;AS: Exchange code for token (client ID + secret)
    AS-&gt;&gt;C: Access token (+ refresh token)
    C-&gt;&gt;RS: API request + access token
    RS-&gt;&gt;C: Calendar data
</code></pre>

<p>A detail worth pausing on: why the two-step dance? Why not just return the token directly after the user approves? The answer is that the authorization code travels through the browser — via URL redirects — which is a less controlled environment. The token exchange, by contrast, happens in a direct server-to-server call that never touches the browser, and it requires the client to authenticate with its client secret. This means that even if an attacker intercepts the authorization code, they can’t use it without also having the client secret. The token itself never passes through the browser at all.</p>

<p><strong>AI scenario</strong></p>

<p>This is the right flow for the moment a user first connects their Google Calendar to the AI secretary. The user is present, the consent screen is shown, and the app ends up with an access token — and typically a refresh token — that it can use going forward. The Refresh Token flow (covered in 5.4) is what keeps that access alive after the access token expires, without requiring the user to go through this process again.</p>

<p><strong>Security notes</strong></p>

<p>This flow requires a <strong>confidential client</strong> — an application that can securely store a client secret on a server. That rules out browser-based apps (where any JavaScript can be inspected) and native mobile or desktop apps (where secrets can be extracted from the binary). If your client can’t safely store a secret, the base Authorization Code flow isn’t sufficient on its own — which is exactly the problem PKCE solves, covered next.</p>

<h3 id="authorization-code-grant--pkce">Authorization Code Grant + PKCE</h3>

<p>The base Authorization Code flow has one dependency that not every client can meet: a client secret. Secrets work well on a server you control, but a mobile app is a different story — the app binary is distributed to users’ devices, and anything embedded in it can be extracted. There’s no safe place to put a secret in a mobile app. The same is true for browser-based single-page apps and CLI tools.</p>

<p>Without a client secret, the token exchange step loses its authentication: anyone who intercepts the authorization code can exchange it for a token themselves. PKCE (Proof Key for Code Exchange) closes this gap without requiring a secret. Instead of authenticating the client with something it knows (a secret), it proves that the party completing the exchange is the same one that started it.</p>

<p><strong>How it works</strong></p>

<p>Before initiating the authorization request, the client generates a random string called the <strong>code verifier</strong>. It then hashes it to produce the <strong>code challenge</strong>, which gets sent along with the authorization request. The Authorization Server stores the challenge. When the client later exchanges the authorization code for a token, it includes the original code verifier. The Authorization Server hashes it and checks it against the stored challenge. If they match, it knows the exchange is coming from the same party that started the flow — no secret required.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant U as User (Mobile App)
    participant C as Client (AI Secretary Mobile)
    participant AS as Authorization Server
    participant RS as Resource Server (Calendar API)

    C-&gt;&gt;C: Generate code_verifier, derive code_challenge = hash(code_verifier)
    C-&gt;&gt;U: Redirect to Authorization Server with code_challenge
    U-&gt;&gt;AS: User authenticates and sees consent screen
    AS-&gt;&gt;AS: Store code_challenge
    AS-&gt;&gt;U: Redirect back to Client with authorization code
    U-&gt;&gt;C: Authorization code delivered via redirect
    C-&gt;&gt;AS: Exchange code + code_verifier for token (no client secret)
    AS-&gt;&gt;AS: Verify hash(code_verifier) matches stored code_challenge
    AS-&gt;&gt;C: Access token (+ refresh token)
    C-&gt;&gt;RS: API request + access token
    RS-&gt;&gt;C: Calendar data
</code></pre>

<p><strong>AI scenario</strong></p>

<p>Your users want a mobile version of the AI secretary. The app needs to request access to their Google Calendar just like the web version does — but it can’t store a client secret. PKCE is what makes this possible. The user taps “Connect Calendar,” gets redirected to Google’s consent screen, approves, and the app ends up with a token — the same end result as the web flow, achieved without a secret.</p>

<p><strong>Security notes</strong></p>

<p>PKCE was originally designed for public clients, but it’s now considered best practice for all clients regardless of whether they can store a secret. Even for confidential clients, PKCE provides an additional layer of protection against authorization code interception. OAuth 2.1 — the in-progress update to the spec — requires PKCE for all clients, public or not.</p>

<h3 id="client-credentials-grant">Client Credentials Grant</h3>

<p>Every flow we’ve covered so far has involved a user — someone who authenticates, sees a consent screen, and approves. The Client Credentials Grant removes the user entirely. It’s designed for machine-to-machine scenarios where the client is acting on its own behalf, not delegating from anyone.</p>

<p><strong>How it works</strong></p>

<p>The client sends its client ID and client secret directly to the Authorization Server’s token endpoint. The Authorization Server validates the credentials and returns an access token. That’s the entire flow — no redirects, no consent screen, no user interaction of any kind.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant C as Client (AI Secretary Backend)
    participant AS as Authorization Server
    participant RS as Resource Server (Weather API)

    C-&gt;&gt;AS: Token request (client ID + secret, requested scopes)
    AS-&gt;&gt;AS: Validate client credentials
    AS-&gt;&gt;C: Access token
    C-&gt;&gt;RS: API request + access token
    RS-&gt;&gt;C: Weather data
</code></pre>

<p><strong>AI scenario</strong></p>

<p>This is the flow for the weather API example from the Roles section. The AI secretary wants to pull in forecast data to help schedule outdoor meetings — but that data doesn’t belong to any user. The agent is accessing it on its own behalf, as the application. Client Credentials is also the right choice for any background processing your system does without a user present: a pipeline that runs overnight to summarize meetings, a scheduled job that syncs data, or a service that calls another internal service.</p>

<p><strong>Security notes</strong></p>

<p>Because there’s no user in the loop, the scopes granted here represent what the application itself is authorized to do — not what any particular user has delegated. This means scope configuration happens at registration time, when the client is set up with the Authorization Server. Getting this right matters: an overly permissive client registered with broad scopes creates a standing risk, because those scopes are available to anyone who obtains the client credentials.</p>

<p>Refresh tokens are typically not issued for this flow. Since the client has its own credentials and can authenticate directly at any time, there’s no need for a long-lived refresh token — when the access token expires, the client simply requests a new one. That’s a meaningful difference from the Authorization Code flows, where re-authenticating would require pulling the user back in. Which is exactly the problem the next grant type solves.</p>

<h3 id="refresh-token-grant">Refresh Token Grant</h3>

<p>Access tokens are intentionally short-lived — typically valid for an hour or less. This is a feature, not a limitation: a short-lived token that gets compromised stops being useful quickly. But it creates a practical problem. The AI secretary was authorized by the user once, and it needs to keep accessing their calendar for weeks or months. Requiring the user to re-authorize every time the token expires would be a terrible experience. The Refresh Token Grant is what bridges that gap.</p>

<p>Unlike the previous flows, this isn’t something you choose as an authorization strategy. It’s a companion to Authorization Code (and Device Authorization, covered next) — the mechanism that keeps access alive after the initial authorization without requiring the user to come back.</p>

<p><strong>How it works</strong></p>

<p>When the Authorization Server issues an access token at the end of the Authorization Code flow, it typically also issues a refresh token. The refresh token is longer-lived and stored securely by the client. When the access token expires, the client sends the refresh token to the Authorization Server’s token endpoint. If the refresh token is still valid — and the user hasn’t revoked access — the Authorization Server issues a new access token. No user interaction required.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant C as Client (AI Secretary)
    participant AS as Authorization Server
    participant RS as Resource Server (Calendar API)

    C-&gt;&gt;RS: API request + access token
    RS-&gt;&gt;C: 401 Unauthorized (token expired)
    C-&gt;&gt;AS: Refresh request (refresh token + client credentials)
    AS-&gt;&gt;AS: Validate refresh token
    AS-&gt;&gt;C: New access token (+ new refresh token)
    C-&gt;&gt;RS: Retry API request + new access token
    RS-&gt;&gt;C: Calendar data
</code></pre>

<p><strong>AI scenario</strong></p>

<p>The user connected their Google Calendar to the AI secretary two weeks ago. Since then, the agent has been quietly joining calls, parsing transcripts, and scheduling follow-ups — all without the user thinking about authorization again. Each time the access token expires, the agent uses the refresh token to get a new one in the background. From the user’s perspective, it just works.</p>

<p><strong>Security notes</strong></p>

<p>Refresh tokens are high-value credentials — they’re long-lived and can be used to generate new access tokens repeatedly. Losing one is more serious than losing an access token, which will at least expire on its own.</p>

<p>Token rotation is the main mitigation: each time a refresh token is used, the Authorization Server should issue a new one and invalidate the old. This means a stolen refresh token can only be used until the legitimate client uses it first — at which point the stolen token becomes worthless. Better implementations also detect reuse: if a refresh token that’s already been rotated shows up again, it’s a signal that something may be compromised, and the Authorization Server can invalidate the entire token family.</p>

<p>It’s also worth knowing that most Authorization Servers set a maximum lifetime on the refresh token chain — after a certain number of rotations, or after a certain total duration, the refresh token expires and the user must re-authenticate. This is intentional: it ensures that long-term access can’t persist forever purely on automation, and that users periodically reconfirm they still want the integration active.</p>

<p>For AI agents, refresh tokens deserve particular care. An agent that holds a refresh token effectively has long-lived access to a user’s resources — as long as the token isn’t revoked and keeps getting rotated within the Authorization Server’s limits. That’s a significant trust surface. Store refresh tokens with the same care you’d give a password, and make sure your system handles token revocation (when a user disconnects the integration) and re-authentication prompts cleanly.</p>

<h3 id="device-authorization-grant">Device Authorization Grant</h3>

<p>The Authorization Code flow assumes the device initiating the request has a browser and can handle redirects. That assumption breaks down quickly in agentic contexts. A CLI tool running in a terminal can’t open a browser window and receive a redirect. A headless agent running on a server has no UI at all. The Device Authorization Grant is designed for exactly these situations — devices that need user authorization but can’t complete a browser-based redirect flow.</p>

<p>The solution is to decouple the authorization from the device making the request. The device gets a code and a URL, and the user completes the authorization on a different device — typically their phone or laptop — while the original device waits.</p>

<p><strong>How it works</strong></p>

<p>The client sends a request to the Authorization Server’s device authorization endpoint. The AS responds with two things: a <code class="language-plaintext highlighter-rouge">device_code</code> (used internally by the client) and a short <code class="language-plaintext highlighter-rouge">user_code</code> (meant for the user), along with a <code class="language-plaintext highlighter-rouge">verification_uri</code> where the user should go to enter it. The client displays the URL and code to the user and starts polling the AS at regular intervals. Meanwhile, the user opens the URL on another device, authenticates, and enters the code. Once the AS sees that the user has approved, the next poll from the client returns an access token.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant C as Client (AI Secretary CLI)
    participant AS as Authorization Server
    participant U as User (Phone or Laptop)
    participant RS as Resource Server (Calendar API)

    C-&gt;&gt;AS: Device authorization request
    AS-&gt;&gt;C: device_code, user_code, verification_uri
    C-&gt;&gt;U: Display "Go to example.com/activate and enter: XKCD-42"
    loop Poll until approved or expired
        C-&gt;&gt;AS: Poll with device_code
        AS-&gt;&gt;C: authorization_pending...
    end
    U-&gt;&gt;AS: Opens verification_uri, authenticates, enters user_code
    AS-&gt;&gt;C: Access token (+ refresh token) on next poll
    C-&gt;&gt;RS: API request + access token
    RS-&gt;&gt;C: Calendar data
</code></pre>

<p><strong>AI scenario</strong></p>

<p>The AI secretary ships a terminal-based version for engineers who prefer working without a GUI. When a user sets it up for the first time, the CLI prints something like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>To connect your Google Calendar, open this URL on your phone or browser:
  https://accounts.google.com/device

And enter the code: XKCD-42

Waiting for authorization...
</code></pre></div></div>

<p>The user opens the link, logs in, enters the code, and the CLI receives its token — no browser on the local machine required. From there, the Refresh Token flow takes over to keep access alive without repeating this process.</p>

<p><strong>Security notes</strong></p>

<p>The <code class="language-plaintext highlighter-rouge">user_code</code> is intentionally short and human-typeable, which means it has a limited character space. To compensate, it expires quickly — typically in 15 minutes or less. If the user doesn’t complete authorization in that window, the flow must be restarted.</p>

<p>The polling interval matters too: clients must respect the interval returned by the AS and not poll more aggressively. Some AS implementations will return a <code class="language-plaintext highlighter-rouge">slow_down</code> response if a client polls too frequently, temporarily increasing the required interval.</p>

<p>For agentic use, this flow is well-suited for one-time setup of a long-running agent that will then maintain access via refresh tokens. The user experience is a single authorization moment at setup time, after which the agent operates autonomously within the bounds of what was approved.</p>

<h3 id="token-exchange-rfc-8693">Token Exchange (RFC 8693)</h3>

<p>The flows covered so far all involve a single client obtaining a token and using it. Multi-agent systems introduce a different problem: what happens when one agent needs to delegate work to another?</p>

<p>Consider the AI secretary in a more complex configuration. The main orchestrator agent receives a user’s token after Authorization Code flow — it’s authorized to access the user’s calendar. Now it needs to hand off a sub-task to a specialized writing agent: draft a follow-up email based on what was discussed in the meeting. The writing agent needs to act in the context of that user — but it doesn’t have a token, and the orchestrator can’t just hand its own token over. That would give the writing agent the same level of access as the orchestrator, with no record of the delegation happening.</p>

<p>Token Exchange (RFC 8693) solves this. It lets a client present an existing token to the Authorization Server and request a new one — scoped down, targeted at a specific audience, and cryptographically encoding who is acting on behalf of whom.</p>

<p><strong>How it works</strong></p>

<p>The client sends a token exchange request to the Authorization Server’s token endpoint, including a <code class="language-plaintext highlighter-rouge">subject_token</code> (the token representing the user) and an <code class="language-plaintext highlighter-rouge">actor_token</code> (the token representing the agent making the request). The AS validates both tokens and issues a new one. The resulting token contains the standard <code class="language-plaintext highlighter-rouge">sub</code> claim identifying the user, plus an <code class="language-plaintext highlighter-rouge">act</code> claim identifying the agent that is acting — creating a verifiable record of the delegation chain.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant OA as Orchestrator Agent
    participant AS as Authorization Server
    participant WA as Writing Agent
    participant RS as Resource Server (Email API)

    OA-&gt;&gt;AS: Token exchange request (subject_token: user token, actor_token: orchestrator token)
    AS-&gt;&gt;AS: Validate both tokens, issue delegated token
    AS-&gt;&gt;OA: Delegated token (sub: user, act: orchestrator agent)
    OA-&gt;&gt;WA: Call with delegated token
    WA-&gt;&gt;RS: API request + delegated token
    RS-&gt;&gt;WA: Access granted (scoped to user, audit trail preserved)
</code></pre>

<p><strong>AI scenario</strong></p>

<p>The orchestrator agent has a token for the user — it’s allowed to read calendar events and manage scheduling. It delegates the email drafting task to a writing agent, using Token Exchange to request a new token scoped only to <code class="language-plaintext highlighter-rouge">email.compose</code> — the minimum the writing agent needs. The AS issues a token where <code class="language-plaintext highlighter-rouge">sub</code> is still the user and <code class="language-plaintext highlighter-rouge">act</code> identifies the orchestrator as the delegating party. If anything goes wrong, the audit trail shows exactly which agent did what and under whose authority.</p>

<p><strong>Security notes</strong></p>

<p>The <code class="language-plaintext highlighter-rouge">act</code> claim is what makes delegation auditable. Each hop in a multi-agent pipeline can be recorded in the token, creating a chain: Agent C acting on behalf of Agent B acting on behalf of User X. Resource Servers can inspect this chain to enforce policies — for example, refusing to honor a token that has passed through more than two hops.</p>

<p>The most important principle here is scope reduction. The delegated token should carry only the scopes needed for the specific sub-task — not the full set of permissions from the original token. Passing down a maximally-permissive token through a chain of agents is exactly the kind of thing that turns a compromised sub-agent into a wide-open breach. Token Exchange gives you the mechanism to prevent that; it’s worth using it deliberately.</p>

<h2 id="oauth-20-vs-openid-connect">OAuth 2.0 vs. OpenID Connect</h2>

<p>OAuth 2.0 and OpenID Connect are closely related, often used together, and frequently confused. The distinction is conceptually clean: OAuth 2.0 is about authorization — what a token is allowed to do. OpenID Connect is about authentication — who the user is. OIDC is built directly on top of OAuth 2.0 and extends it with a small set of additions specifically designed to convey user identity.</p>

<p><strong>What OIDC adds</strong></p>

<p>When a Client includes the <code class="language-plaintext highlighter-rouge">openid</code> scope in an OAuth authorization request, it signals that it wants OIDC. The Authorization Server responds not just with an access token, but also with an <strong>ID token</strong> — a separate JWT whose purpose is to tell the Client who just authenticated and how. Where an access token is intended for the Resource Server (“here’s your authorization to act”), the ID token is intended for the Client itself (“here’s who logged in”).</p>

<p>OIDC also defines a <strong>UserInfo endpoint</strong> — an API the Client can call using the access token to retrieve additional profile claims about the user. Rather than packing everything into the ID token, OIDC keeps the ID token lean and lets the Client fetch richer profile data separately when needed.</p>

<p><strong>Claims: what’s new and what you already know</strong></p>

<p>The <code class="language-plaintext highlighter-rouge">sub</code> claim appeared back in the JWT section, and it’s present in both access tokens and ID tokens. In an access token, <code class="language-plaintext highlighter-rouge">sub</code> identifies the user for the Resource Server — it’s the identifier the application uses in its own database queries to scope what gets returned. In an ID token, <code class="language-plaintext highlighter-rouge">sub</code> serves the same identifying role, but the audience is the Client application itself, which uses it to know who just signed in.</p>

<p>OIDC introduces a separate set of profile claims that don’t appear in access tokens by default. These come in the ID token or from the UserInfo endpoint, and they describe the person rather than their authorization:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">name</code>, <code class="language-plaintext highlighter-rouge">given_name</code>, <code class="language-plaintext highlighter-rouge">family_name</code> — the user’s display and legal name</li>
  <li><code class="language-plaintext highlighter-rouge">email</code> — the user’s email address</li>
  <li><code class="language-plaintext highlighter-rouge">picture</code> — a URL to the user’s profile photo</li>
  <li><code class="language-plaintext highlighter-rouge">auth_time</code> — when the authentication event occurred</li>
  <li><code class="language-plaintext highlighter-rouge">amr</code> — the authentication methods used (e.g., password, hardware key)</li>
</ul>

<p>Which of these you receive depends on the scopes requested. The <code class="language-plaintext highlighter-rouge">profile</code> scope grants access to name and picture; <code class="language-plaintext highlighter-rouge">email</code> grants access to the email address. The <code class="language-plaintext highlighter-rouge">openid</code> scope alone gives you only the minimum: <code class="language-plaintext highlighter-rouge">sub</code> and the authentication metadata.</p>

<p>The practical difference: an access token says “this token can read calendar events for user <code class="language-plaintext highlighter-rouge">user_8472</code>.” An ID token says “user <code class="language-plaintext highlighter-rouge">user_8472</code> is Jane Doe, her email is jane@example.com, and she authenticated two minutes ago using a hardware key.” One is about permission; the other is about identity.</p>

<p><strong>In practice, the lines blur</strong></p>

<p>The conceptual separation between authentication and authorization is clean on paper. Real-world identity providers — Google, Okta, Auth0, Cognito — implement both OIDC and OAuth 2.0, and they deliver both tokens in the same flow. When a user signs into the AI secretary and connects their Google Calendar, a single authorization request produces an ID token (who this user is) and an access token (what the app can do on their behalf). The protocol is separate; the infrastructure that delivers it is combined.</p>

<p>There’s a second, deeper source of blurriness: authorization doesn’t only happen at the OAuth layer. The Authorization Server controls token-level access — which scopes a token carries, which audiences it’s valid for, when it expires. But the application itself also makes authorization decisions that OAuth knows nothing about: is this user an admin? Do they own this record? Are they allowed to see data from this specific organization? These decisions live in application code, and they typically use the token’s claims as inputs — <code class="language-plaintext highlighter-rouge">sub</code> to identify the user, <code class="language-plaintext highlighter-rouge">email</code> or group membership claims to check roles.</p>

<p>This means authorization in a real system happens at (at least) two layers: what OAuth 2.0 permits at the token level, and what the application permits at the business logic level. A token with <code class="language-plaintext highlighter-rouge">calendar.events</code> scope doesn’t mean the user can see all calendar events — it means the application is allowed to query the calendar API on their behalf. Which events are actually returned is an application decision, informed by the <code class="language-plaintext highlighter-rouge">sub</code> claim and whatever business rules apply. OAuth 2.0 is one layer of authorization, not the whole story.</p>

<h2 id="choosing-the-right-grant-type">Choosing the Right Grant Type</h2>

<pre><code class="language-mermaid">flowchart TD
    A([New resource access scenario]) --&gt; B{Is a user present and authorizing?}
    B --&gt;|No| C[Client Credentials Grant]
    B --&gt;|Yes| D{Can the client use a browser redirect?}
    D --&gt;|No| E[Device Authorization Grant]
    D --&gt;|Yes| F{Can the client securely store a client secret?}
    F --&gt;|Yes| G[Authorization Code Grant]
    F --&gt;|No| H[Authorization Code + PKCE]
    G --&gt; I{Need ongoing access without user re-auth?}
    H --&gt; I
    E --&gt; I
    I --&gt;|Yes| J[+ Refresh Token Grant]
    I --&gt;|No| K([Done])
    J --&gt; K
    C --&gt; K
</code></pre>

<blockquote>
  <p><strong>Agent-to-agent delegation:</strong> when an agent needs to call another agent while preserving the user’s identity, apply <strong>Token Exchange (RFC 8693)</strong> on top of whichever flow issued the original token.</p>
</blockquote>

<p><strong>When to use each path</strong></p>

<p><strong>Client Credentials</strong> is the right choice whenever there’s no user in the loop — a background pipeline, a scheduled job, or any service-to-service call where the client is acting on its own behalf. The agent authenticates directly with its credentials and gets a token back. No redirects, no consent screen.</p>

<p><strong>Device Authorization</strong> handles the cases where a user needs to authorize something but the client can’t complete a redirect — a CLI tool, a headless agent, or any environment without a browser. The user approves on a separate device while the client polls for the result.</p>

<p><strong>Authorization Code</strong> is for server-side applications that can store a client secret and need to request user-delegated access. The two-step code exchange keeps the token out of the browser.</p>

<p><strong>Authorization Code + PKCE</strong> covers the same user-delegated scenario for clients that can’t store a secret — mobile apps, single-page apps, desktop tools. PKCE is also recommended on top of the base Authorization Code flow even for confidential clients.</p>

<p><strong>Refresh Token</strong> isn’t a standalone choice — it’s the companion to Authorization Code and Device Authorization that keeps access alive after the initial token expires, without requiring the user to re-authorize.</p>

<p><strong>Token Exchange</strong> sits outside the main tree because it’s not how a client gets its first token — it’s what happens when an agent needs to delegate work to another agent mid-flow. The calling agent exchanges its token for a new one that is scoped down and carries the delegation trail.</p>

<p>Most real-world agentic applications use several of these at once. The AI secretary uses Authorization Code + PKCE for the mobile app, Refresh Token to maintain calendar access, Client Credentials for the weather API, and Token Exchange when it delegates tasks to sub-agents. Each resource access interaction has its own flow, its own token, and its own scope.</p>

<p><strong>Protecting the agent itself</strong></p>

<p>Choosing the right grant type covers how your agent accesses things. But there’s another side to this: what happens when something accesses your agent.</p>

<p>Throughout this post we’ve been looking at the agent as a Client — the party requesting access to external resources. But agents also receive requests. A frontend calls your agent on behalf of a user. An orchestrator delegates a task to your agent. Another service calls your agent directly. When that happens, your agent is the Resource Server, and it needs to protect itself with the same rigor we’ve applied everywhere else.</p>

<p>Every incoming request to your agent should carry a token, and your agent should validate it fully before acting. The checklist:</p>

<ul>
  <li><strong>Verify the signature</strong> — confirm the token was signed by a trusted Authorization Server using its public key</li>
  <li><strong>Check <code class="language-plaintext highlighter-rouge">iss</code></strong> — confirm it was issued by an Authorization Server you actually trust</li>
  <li><strong>Check <code class="language-plaintext highlighter-rouge">aud</code></strong> — confirm the token was issued specifically for your agent. This is the guard against token forwarding attacks, where a token obtained for one service is replayed against another. If <code class="language-plaintext highlighter-rouge">aud</code> doesn’t match your agent’s identifier, reject the token</li>
  <li><strong>Check <code class="language-plaintext highlighter-rouge">exp</code></strong> — confirm the token hasn’t expired</li>
  <li><strong>Check <code class="language-plaintext highlighter-rouge">scope</code></strong> — confirm the caller has the permissions required for the specific operation they’re requesting</li>
</ul>

<p>Beyond the baseline, different caller types warrant different handling.</p>

<p><strong>A frontend calling on behalf of a user</strong> — the token carries a <code class="language-plaintext highlighter-rouge">sub</code> (the user’s identity) and the scopes the user authorized. Your agent operates in that user’s context and applies its own application-layer authorization accordingly.</p>

<p><strong>Another agent calling with a delegated token</strong> — the token carries both <code class="language-plaintext highlighter-rouge">sub</code> (the original user) and an <code class="language-plaintext highlighter-rouge">act</code> claim (the calling agent’s identity). Inspect the <code class="language-plaintext highlighter-rouge">act</code> claim to understand who is delegating, and consider enforcing a delegation depth limit — refusing tokens that have passed through more hops than your policy allows.</p>

<p><strong>Another agent calling on its own behalf</strong> — the token won’t carry a user <code class="language-plaintext highlighter-rouge">sub</code>. Be explicit about which operations are available to machine-to-machine callers versus user-delegated ones, and restrict accordingly.</p>

<p>Finally: define your agent’s own scopes. Just like any Resource Server, your agent should require callers to request specific permissions to invoke it. This makes access intentional and auditable — not a free-for-all for anyone who holds a valid token from a trusted Authorization Server.</p>

<h2 id="tools--libraries">Tools &amp; Libraries</h2>

<p>OAuth 2.0 is a protocol — not something you implement from scratch. In production, the Authorization Server role is almost always handled by a managed identity provider, and the client side by well-maintained libraries. Your job as an AI engineer is to understand the protocol well enough to configure these tools correctly, not to reimplement them.</p>

<p><strong>Identity Providers</strong></p>

<p>Services like Auth0, Okta, AWS Cognito, Google Identity, and Azure Entra ID act as your Authorization Server out of the box. They handle token issuance, scope enforcement, refresh token rotation, consent screens, and more. Which one you use typically comes down to your existing infrastructure: Cognito is a natural fit for AWS-heavy stacks, Entra ID for Microsoft ecosystems, and Auth0 or Okta for teams that want a provider-agnostic solution with strong developer tooling. Keycloak is worth knowing as a self-hosted open-source option for teams that need to keep everything in-house.</p>

<p><strong>Client Libraries</strong></p>

<p>When your application needs to act as an OAuth Client, most identity providers ship their own SDKs that abstract away the raw protocol work. For cases where you’re integrating with a provider that doesn’t have a dedicated SDK, or when you want more control, general-purpose libraries like Authlib (Python) and openid-client (Node.js) cover the full OAuth 2.0 and OIDC surface area.</p>

<p>The libraries handle the mechanics. The protocol knowledge you now have is what lets you configure them correctly, pick the right grant type, scope tokens appropriately, and diagnose problems when something breaks.</p>

<hr />

<p>The AI secretary we started with — joining calls, scheduling meetings, accessing calendars — is a useful lens because it’s not a contrived example. It’s the kind of system AI engineers are building right now, and it touches almost every concept in this post: user-delegated access, machine-to-machine calls, token lifetimes, scope design, multi-agent delegation, and the responsibility of protecting your own service from callers.</p>

<p>OAuth 2.0 doesn’t make these problems disappear. What it gives you is a standard, well-understood vocabulary for solving them — one that your tools, your libraries, and your teammates all share. The more deliberately you apply it, the more secure and durable your systems will be.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[This post is a practical guide to OAuth 2.0 for AI engineers. It covers the core concepts — roles, scopes, access tokens, and JWTs — and then goes deep on the grant types most relevant to AI and agentic systems: Authorization Code, Authorization Code with PKCE, Client Credentials, Refresh Token, Device Authorization, and Token Exchange. Along the way, it maps each grant type to real agentic scenarios, explains how OAuth 2.0 relates to OpenID Connect, and gives you a decision framework for choosing the right grant type in your own systems.]]></summary></entry></feed>