Blog · · 10 min read
AI agent authorization: enforce permissions before tool calls

A valid tool call is only a well-formed request. It is not proof that the caller has permission. A model can produce perfect JSON for issue_refund even when the current user cannot refund that order, access that tenant, or approve that amount. Prompt instructions cannot fill that gap.
AI agent authorization belongs between the agent runtime and every product tool. The policy decision should use authenticated identity, tenant, tool, normalized arguments, current product state, and session risk. Only then should the application execute, narrow, defer, or reject the proposed action. The model can suggest what to do. It does not get to decide what it may do.
Put authorization between planning and execution
Start by treating every model-generated tool call as a proposal. Application code should pass it through the same sequence every time:
- Authenticate the person or service responsible for the run.
- Resolve the tenant, account, role, and delegated scopes outside the prompt.
- Normalize and validate the proposed tool arguments.
- Evaluate policy for this identity, tool, resource, and action.
- Return
allow,deny,modify,defer, orstep_upwith a reason code. - Execute the authorized version and record the decision beside the result.
A practitioner request in the OpenAI Agents SDK repository describes this missing boundary. The author points out that input and output guardrails inspect content, while authorization decides whether an identity may perform a tool call. The proposed middleware would check identity, role, scope, rate limits, and session context. This is a user report and design proposal, not a feature guarantee from the SDK, but the distinction is useful.
The enforcement point may be a shared tool gateway, an application service, or middleware in the agent host. Two properties matter. Every path to execution must pass through it, including retries and resumed runs. The model must also be unable to alter the identity and policy inputs used for the decision.
Why guardrails do not answer permission questions
Guardrails usually classify or transform content. They may reject prompt injection, personal data, unsafe language, or malformed arguments. Authorization asks whether this actor may perform this operation on this resource under current policy.
Consider a support agent with two tools:
read_customer_profile(customer_id)issue_refund(order_id, amount_minor, destination)
Both calls can contain ordinary business language and valid arguments. Either can still cross a tenant boundary. The refund may exceed the user's limit or target a payment destination that user cannot change. Nothing about the call needs to look suspicious. Permission depends on the actor, tenant, resource, action, and current context.
OWASP's prompt injection guidance recommends privilege control and least-privilege access. It also recommends human approval for high-risk operations. These controls reduce what manipulated model behavior can reach. They do not eliminate prompt injection, and adding them to a system prompt would not turn them into enforcement.
The trust boundary should remain easy to trace. The model proposes a tool and arguments. The application authenticates the actor and resolves tenant membership. The product service owns resource authorization and business invariants. A separate policy decision covers the exact proposed action, after which the tool adapter records what happened.
Existing product permissions still apply when an agent becomes another client. If a user cannot perform an action through the product UI or API, an agent acting for that user should not get a looser route.
Define the context the policy can trust
A tool name alone is not enough for a policy decision. Build an authorization request from values whose owner is unambiguous:
authorization_request:
decision_id: dec_01
run_id: run_482
actor:
user_id: usr_18
tenant_id: tenant_42
roles: [support_specialist]
delegated_scopes: [orders:read, refunds:propose]
authentication_strength: password_and_totp
action:
tool: issue_refund
effect: financial_write
resource_type: order
resource_id: ord_55
normalized_arguments:
amount_minor: 7500
currency: USD
destination: original_payment_method
context:
channel: support_console
session_age_seconds: 420
policy_version: refund_policy_12
Application code supplies actor, tenant_id, authentication strength, and policy version. The model may propose action fields, but it cannot assert its own role or delegated scope. Resource ownership should come from the product database rather than a tenant field copied from model output.
Normalize arguments before evaluating policy. Convert currency to minor units, resolve aliases to canonical IDs, apply required defaults, and reject unknown fields. Policy and execution must inspect the same representation. If an adapter later changes an amount, destination, resource, or another material field, evaluate the changed request again.
The tool registry needs security metadata as well. Record the effect class, resource type, required scopes, reversibility, approval support, and risk-sensitive arguments for each tool. This makes a rule such as "financial writes above the user's limit require approval" inspectable. It is safer than scattering string checks across tool implementations.
Implement AI agent authorization before every tool call
Use one wrapper for every invocation. That includes subagent calls, retries, background workers, and callbacks that resume a paused run. An internal route is still an execution route.
def invoke_authorized_tool(run, proposed_call, authenticated_actor):
action = normalize_and_validate(proposed_call)
resource = load_resource(action.resource_type, action.resource_id)
request = build_authorization_request(
run=run,
actor=authenticated_actor,
action=action,
resource=resource,
policy_version=current_policy_version(),
)
decision = policy_engine.evaluate(request)
record_decision(request, decision)
if decision.kind == "deny":
raise PermissionDenied(decision.reason_code)
if decision.kind == "defer":
return queue_for_manual_review(request, decision)
if decision.kind == "step_up":
return request_stronger_authentication(request, decision)
if decision.kind == "modify":
action = validate_policy_rewrite(action, decision.replacement)
if decision.kind != "allow" and decision.kind != "modify":
raise InvalidPolicyDecision(decision.kind)
return execute_with_receipt(
action=action,
actor=authenticated_actor,
authorization_decision_id=decision.id,
)
Keep the decision vocabulary small enough for the host application to handle directly:
| Decision | Meaning | Required product behavior |
|---|---|---|
allow | This exact action may execute now | Execute the normalized action and bind the receipt to the decision ID |
deny | Policy forbids the action | Return a typed permission failure without asking the model to find a bypass |
modify | Policy permits a narrower action | Validate the replacement and show material changes to the user |
defer | A person or external process must decide | Persist the request and stop execution |
step_up | Stronger authentication is required | Challenge the actor, then evaluate again with fresh authentication context |
A denial must reach the host as a typed outcome. Do not let the model turn it into a success message or look for another tool that reaches the same side effect. The model may explain an application-provided reason to the user, but it cannot redefine the outcome.
Authorize arguments and resources, not just tool names
A tool allowlist is too coarse for most existing products. The safety of issue_refund depends on the target order, refund amount, destination, tenant, and actor.
A refund policy might require the order to belong to the actor's tenant and remain visible to that actor. It can cap the amount at the captured total and at the actor's personal limit. It may allow only the original payment method unless the user completes stronger authentication. Larger amounts can move to a manager approval flow.
Other tools need different controls. An email action may restrict recipients, customer relationships, attachments, and daily limits. A data tool may expose a named read model while refusing raw SQL. These checks belong in product policy, where engineers can test them against real resources and identities.
The underlying service should continue to enforce its domain rules. The policy layer makes a decision before execution, but it does not replace database constraints, row-level security, or API authorization. Protect non-agent callers of the same tool path too.
When policy returns modify, retain both the model's proposal and the authorized replacement. A reduced date range or redacted field set may be acceptable. Silent rewriting is harder to audit and explain, especially when the change affects what the user sees. Show material changes or request confirmation before execution.
Keep credentials and scopes narrow
A policy gateway does not make a broad service token safe. If another execution path bypasses the gateway, that token exposes everything it can reach.
The MCP security best-practices guide warns against token passthrough. In that pattern, a server accepts a client token without validating that the token was issued for the server, then forwards it downstream. The guide explains how this can bypass service-specific controls and weaken accountability. It also recommends progressive, least-privilege scopes because broad scopes increase the impact of token compromise and obscure the user's intent in audit records.
Use credentials with a defined audience, tenant, and narrow capability. When the infrastructure supports it, exchange or mint short-lived credentials at the boundary. Keep raw tokens out of prompts, traces, approval records, and model-visible tool results. A policy request needs credential identity and scope, not the credential value.
A shared gateway can become a confused deputy when it serves many clients and delegates to third-party APIs. Bind consent and authorization to the initiating user and intended client. Possession of a callback, run ID, or session ID is not enough to use another user's grant.
Handle denial, policy outages, and stale context
A denial is a normal product outcome. Return a stable reason code such as tenant_mismatch, scope_missing, resource_forbidden, or step_up_required. User-facing copy should avoid private policy details, while the audit record keeps enough information for investigation.
High-impact writes should fail closed when the policy service is unavailable. The application can queue the proposal or ask the user to retry, but it should not call the tool. Any fallback for a low-risk read must be narrow, documented, and approved by the product's security owner. Individual tool authors should not invent outage behavior as they go.
Policy context can become stale between decision and execution. Reload critical resource state before the side effect. Bind the decision to an action hash, resource version, or short expiry, then evaluate again when ownership, amount, destination, role, authentication strength, or another material field changes.
Retries should reuse the authorized action identity instead of asking the model to recreate the call. The execution record needs to distinguish a failure before the side effect from an unknown outcome after the remote system may have committed. Authorization controls permission. Idempotency and reconciliation handle duplicate or uncertain execution.
Keep policy decisions separate from model narration in the logs. A useful record includes the decision ID, actor and tenant IDs, tool, resource, normalized action hash, policy version, result, reason code, timestamp, and execution receipt. Redact sensitive arguments under the product's normal logging rules.
Verify the authorization boundary
Test the wrapper as a product security boundary rather than stopping at an agent demo:
- Call every registered tool through the normal runtime and prove that the wrapper records a decision before execution.
- Attempt the same resource ID from another tenant and require a denial before the tool adapter runs.
- Put an administrator role inside the prompt while authenticating a basic user. The prompt value should have no effect.
- Change a material argument after authorization and confirm that execution stops or policy runs again.
- Remove a role between proposal and execution, then confirm stale permission cannot authorize the action.
- Request a broad token scope and verify that the credential layer grants only what the approved tool needs.
- Simulate a policy timeout during a financial write and confirm that the tool is not called.
- Submit a
step_upaction without fresh authentication. A copied session or callback must not satisfy the challenge. - Retry the same authorized action and verify that its execution receipt prevents an unintended duplicate side effect.
- Trace one decision ID through model proposal, policy, tool execution, and final product outcome.
CI should include a bypass test. Enumerate the tool registry and fail when an executable tool lacks policy metadata or the shared wrapper. This catches a quiet but serious regression: an internal helper becomes model-callable without joining the authorization path.
Common mistakes
System-prompt rules are guidance, not authorization. The model can follow them, ignore them, or encounter untrusted text that changes its behavior. Permission checks need trusted application context.
Checking only a tool name misses the facts that usually matter. Resource ownership, arguments, effect, tenant, and current state determine whether an action is allowed.
Identity claims from the model are untrusted input. User, tenant, role, authentication strength, and delegated scope must come from the authenticated product session.
Human approval is not a universal escape hatch. Some actions are forbidden rather than reviewable. A manager approval should not cross a tenant boundary or override a product invariant that has no exception path.
An allow record is incomplete without an execution result. Join the decision to the action receipt so an investigator can tell whether the tool ran, failed, produced an unknown outcome, or was delivered twice.
What to do next
Pick one write tool that an agent can already call. Document its resource, effect class, required scopes, argument-level rules, step-up conditions, and denied outcomes. Put it behind the shared wrapper, then run the cross-tenant, stale-role, changed-argument, policy-outage, and duplicate-execution tests above.
After that path is closed, require the wrapper for every entry in the tool registry. AI agent authorization is working when each execution route proves permission from trusted product context before it reaches the underlying service.
References
- OpenAI Agents SDK issue #2868: Per-tool authorization middleware for agent tool calls provides practitioner problem evidence and a proposal for identity-, role-, scope-, rate-, and session-aware decisions before tool execution.
- Model Context Protocol: Security best practices supports the guidance on token audience validation, token-passthrough risks, confused-deputy threats, auditability, and progressive least-privilege scopes.
- OWASP LLM01: Prompt injection supports privilege control, least-privilege access, and human approval for high-risk operations as limits on connected model behavior.