---
title: "Who May Call What: Per-User Authorization on AgentCore With Cedar"
description: "A valid token proves who is calling. AgentCore Policy is a Cedar engine on the gateway that answers who-may-call-what per user, per tool, per argument — default-deny, before the tool runs. I wired one up and watched it allow, then deny, the same call."
canonical_url: "https://artificialcuriositylabs.ai/posts/per-user-authorization-cedar/"
md_url: "https://artificialcuriositylabs.ai/posts/per-user-authorization-cedar.md"
published_at: "2026-07-12T00:00:00.000Z"
tags:
  - "agents"
  - "agentcore"
  - "bedrock"
---

A single agent in production can fire thousands of tool calls an hour, and the token it carries was designed for a human clicking "Allow" on a consent screen. That mismatch is a quiet security debt: the token proves *who* is calling, but nothing decides *what* they may do once they are in. A valid token is a passport, not a key to every room.

AgentCore splits those two jobs. The gateway's authorizer checks the passport. A separate Cedar policy engine decides which rooms open — per user, per tool, and even per argument, evaluated before the tool runs. I stood one up on a throwaway gateway and ran the same tool call through it as I added policies, watching the decision flip from allow to deny to conditionally-allow. This post is what the second layer is, and what it actually did.

## Authentication is not authorization

On an AgentCore Gateway, the inbound authorizer validates identity: a JWT's issuer, audience, signature, and expiry, or an IAM caller's SigV4 signature. Pass it and you are *known*. You are not yet *allowed*.

I proved the gap directly. On a gateway with no policy engine attached, a call to a `process_refund` tool for $600 returned `isError:false` — it just ran. No policy, no question asked. "Any authenticated caller" was the effective rule, and that is almost never the rule you want. What you want is "this user may call this tool," or "callers with the `finance` role may refund under $500," or "read tools for everyone, write tools for me." Expressing that is authorization, and the authorizer does not do it. Without a policy layer, a gateway is a door that checks IDs and then lets everyone into every room behind it.

## Policy is a Cedar engine on the gateway

AgentCore's answer is [Policy](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-core-concepts.html): a policy engine, expressed in [Cedar](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-understanding-cedar.html), that you attach to a gateway with one configuration field. For every tool invocation the engine evaluates its policies and returns allow or deny *before the tool runs*. It is not a library you call from inside your handler — it is a gate in front of the handler, applied by the gateway itself.

```mermaid
sequenceDiagram
    participant C as Caller (agent)
    participant A as Gateway authorizer
    participant P as Cedar policy engine
    participant T as Tool (Lambda)
    C->>A: tool call + token
    A->>A: validate token (authN)
    A->>P: authorize? principal + action + args
    alt a permit matches and no forbid
        P-->>A: ALLOW
        A->>T: invoke
        T-->>C: result
    else default-deny or forbid
        P-->>A: DENY
        A-->>C: -32002 Tool Execution Denied
    end
```

The tool is only reached on ALLOW. Authentication and authorization are two gates in series: the authorizer proves the token, the engine decides the action.

Cedar is an open-source authorization language AWS built to be both human-readable and machine-analyzable. A policy names three things and an optional condition:

- **principal** — who is calling
- **action** — the tool
- **resource** — the gateway
- **when / unless** — the conditions that must hold

The engine turns each incoming call into that shape and checks it against every policy. The moment I attached an engine with no policies in it and repeated the $600 refund, the answer changed:

```
code -32002
Tool Execution Denied: Tool call not allowed due to policy enforcement
[No policy applies to the request (denied by default).]
```

Same call, same tool, opposite outcome — because the posture flipped to default-deny.

## Default deny is the posture

If no policy matches a request, the result is DENY. You do not write rules to block things; you write rules to permit things, and everything else is blocked already. An empty policy engine denies every call, and it says so — the denial names its reason. That is the blast-radius control: attach the engine and the whole tool surface locks until you explicitly open parts of it. A confused or compromised agent cannot call what no policy permitted.

To reopen `read_doc` and nothing else, one `permit`:

```cedar
permit(
  principal == AgentCore::OAuthUser::"<user-sub>",
  action == AgentCore::Action::"acxecho___read_doc",
  resource == AgentCore::Gateway::"<gateway-arn>"
);
```

After that policy went active, `read_doc` returned `isError:false` again — while `process_refund` stayed denied by default. One action opened; the rest still shut.

(The action id, `acxecho___read_doc`, is `<target>___<tool>` — the gateway's namespaced tool name, which must match the schema the engine auto-generates from your tool definitions.)

## Conditions read the tool's own arguments

The capability that makes this more than role-gating is the `when` clause. It can read the tool's arguments, so authorization decides on *what is being asked*, not just *who is asking*. Here is a permit for refunds under $500:

```cedar
permit(
  principal == AgentCore::OAuthUser::"<user-sub>",
  action == AgentCore::Action::"acxecho___process_refund",
  resource == AgentCore::Gateway::"<gateway-arn>"
)
when {
  context.input.amount.lessThan(decimal("500.0"))
};
```

With that active, the argument decided the call:

- `amount = 450.0` → **ALLOW**
- `amount = 600.0` → **DENY** — "No policy applies (denied by default)": the `when` failed, so the permit did not apply, so default-deny caught it.

Same principal, same action, decision made on the value. This is the control the field has been asking for by name — per-action authorization that limits the damage a hijacked or prompt-injected agent can do, because "call the refund tool" is not the same grant as "call the refund tool for $600." (One detail worth stating flat: a tool argument typed `number` becomes a Cedar decimal, so the comparison uses `decimal("500.0")` and the caller must send `450.0`, not `450`.)

## Forbid overrides permit

The second rule: a `forbid` beats any `permit` for the same request. This lets you write a broad allow and carve exceptions out of it. I kept the broad `read_doc` permit and added a forbid for one document:

```cedar
forbid(
  principal,
  action == AgentCore::Action::"acxecho___read_doc",
  resource == AgentCore::Gateway::"<gateway-arn>"
)
when { context.input.docId == "secret" };
```

`docId = "d-1"` was allowed; `docId = "secret"` was denied — and the denial read differently:

```
Policy evaluation denied due to forbid_secret_doc-<id>
```

That difference matters. A default-deny says *nothing permitted this*; a forbid-deny names *the rule that overrode a permit*. The engine tells you which of the two happened. This is the "everyone may read, except this suspended user / this sensitive record" pattern, and it is decisive by construction — no permit can win it back.

## The two layers, on one call

Put authentication back and you see both layers act on the same tool. On a gateway fronted by Cognito, the same valid user token produced different outcomes, and a missing token failed earlier:

| Call | Deciding layer | Result |
|---|---|---|
| valid token → `read_doc` (permitted) | authorization | ALLOW `isError:false` |
| valid token → `process_refund` (not permitted) | authorization | DENY `-32002` (default-deny) |
| no token → any tool | authentication | 401 `-32001` "Missing Bearer token" |

Two error codes for two jobs: `-32001` is the passport check failing before policy runs; `-32002` is policy deciding no. The user's `sub` claim from the token became the Cedar principal — the seam where identity feeds authorization. The same real token is allowed for one tool and denied for another, purely on policy. That is authentication and authorization doing separate jobs, visibly.

## Why it belongs on the gateway, not in the tool

You could check claims inside each tool handler. Every reason not to is why Policy is a separate layer.

A tool that enforces its own authorization has to be trusted to do it, and re-trusted every time it changes. Ten tools is ten implementations of the same claim-checking logic, each a place to get it subtly wrong. On the gateway, authorization is a property of the front door: written once, evaluated uniformly, auditable in one place, and applied before the tool is even invoked. The tool goes back to doing only its job.

It is also analyzable in a way handwritten checks are not. Policies validate against a schema the engine generates from your tool definitions, so a rule referencing a tool that does not exist, or an argument of the wrong type, is caught at creation. AgentCore runs automated reasoning over each policy, too — it refused my first attempt outright:

```
Overly Permissive: Policy Engine will allow every request for the specified
principal (AgentCore::OAuthUser), action (acxecho___read_doc), resource (...)
```

My first permit left the principal unconstrained, and the analyzer rejected it before it could ship as an accidentally-open door. Binding the permit to a concrete user `sub` made it pass. The lesson is built into the tool: a permit with no principal is a mistake, and the engine treats it as one.

## What's missing

I proved the mechanism, not a production system. The tool behind my gateway was an echo Lambda exposing a pretend `read_doc` and `process_refund` — nothing refunded anything. What is real is the enforcement: default-deny, per-user, per-argument, forbid-wins, and creation-time analysis all behaved exactly as described, on live infrastructure. What I did not do is run this in front of a capability that matters, and that last step is where the operational weight sits.

Two things carry that weight. First, per-user rules need per-user identity — a machine-to-machine token carries a *machine* `sub`, not a person's, so meaningful per-user policy presumes you are minting user-scoped tokens upstream. I used a Cognito user to get a real `sub`; a fleet of agents sharing one service credential would collapse every caller into one principal and the per-user story with it. Second, a policy set is software. A wrong `forbid` locks out a legitimate caller; a missing `permit` fails a call that should have worked. Cedar earns you analyzability, but the rules still need review, tests, and version control like any other code. The engine removes the excuse, not the work.

I also hand-wrote every policy. AgentCore has a natural-language authoring service that generates Cedar from plain-English intent and validates it against the schema; I have not run it. For a handful of rules, hand-written Cedar was clearer than describing it in prose.

## So what

A validated token is proof of identity, not permission. I watched a $600 refund sail through a gateway that authenticated no one, and I watched the same call die the instant a default-deny engine sat in front of it. The moment a tool has more than one class of caller — your agents and someone else's, read callers and write callers, a finance role and everyone else — identity alone stops being enough. You need a layer that says who may call what, and it belongs beside your tools, not smeared through them.

AgentCore puts it exactly there: a Cedar engine on the gateway, default-deny, evaluating principal, action, and arguments on every call, before the tool runs. The tool stays a plain handler. The gateway carries the rule. "Who is allowed to do this" becomes a policy you can read, validate, and have a machine prove correct — instead of a conditional buried in code you have to trust.

---

*Part of a series working through Amazon Bedrock AgentCore by building on it. Start with [The AgentCore Map](https://artificialcuriositylabs.ai/posts/the-agentcore-map) for the full picture. This builds on [Two Ways to Authorize an Agent Tool](https://artificialcuriositylabs.ai/posts/authorizing-agent-tools-iam-vs-oauth) — authentication proves the token, this post decides the call. For how to judge what AgentCore does and doesn't do, see [Two Things I Almost Called AgentCore Gaps](https://artificialcuriositylabs.ai/posts/where-agentcore-sits).*
