DEV Community

Amit
Amit

Posted on Originally published at artificialcuriositylabs.ai

Who May Call What: Per-User Authorization on AgentCore With Cedar

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: a policy engine, expressed in Cedar, 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.

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
Enter fullscreen mode Exit fullscreen mode

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).]
Enter fullscreen mode Exit fullscreen mode

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:

permit(
  principal == AgentCore::OAuthUser::"<user-sub>",
  action == AgentCore::Action::"acxecho___read_doc",
  resource == AgentCore::Gateway::"<gateway-arn>"
);
Enter fullscreen mode Exit fullscreen mode

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:

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"))
};
Enter fullscreen mode Exit fullscreen mode

With that active, the argument decided the call:

  • amount = 450.0ALLOW
  • amount = 600.0DENY — "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:

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

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

Policy evaluation denied due to forbid_secret_doc-<id>
Enter fullscreen mode Exit fullscreen mode

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 (...)
Enter fullscreen mode Exit fullscreen mode

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 for the full picture. This builds on Two Ways to Authorize an Agent Tool — 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.

Top comments (0)