Homeβ€Ί Blogβ€Ί AWS Architecture Series #79 β€” The deny that nobody wrote…
AWS Architecture AWS Architecture Series

AWS Architecture Series #79 β€” The deny that nobody wrote

An IsAuthorized response is read as a verdict: ALLOW, or DENY. The API returns two more fields alongside it that say how the verdict was reached, and a DENY looks identical whether somebody forbade the request, nobody permitted it, or the policy meant to permit it failed to evaluate.

Verified against current vendor documentation on 11 October 2026. Pricing, limits and API behaviour were checked against the official docs on that date. Cloud services change fast — if you are reading this much later, treat the specifics as a starting point and re-check the linked sources.

Business Challenge

#35 walked the seven gates an AWS API request passes through. Verified Permissions is the same question asked about your application's resources — a transaction, a document, a button — which IAM has no vocabulary for. It runs Cedar; AWS states the version: “Verified Permissions currently uses Cedar version 4.7”.

I promised this post would explain why a policy store's schema is the thing that constrains authorisation. Having read the documentation, that promise was wrong, and the correction is the most useful thing here: the schema constrains policy authoring, optionally, at write time. It is not consulted when a decision is made.

1A DENY does not say who denied you, unless you read the next field

The decision is binary — “Valid Values: ALLOW | DENY” — and determiningPolicies carries the provenance. Three different situations, one word:

“if there are two matching policies, where one is a forbid and the other is a permit, then the forbid policy will be the determining policy” — somebody wrote a rule against this, and it beat a permit.

“In the case that no policies match, and hence the response is DENY, there would be no determining policies” — nobody granted it. Nobody forbade it either.

Those are different incidents. One is a control working. The other is usually a policy that was never written, or was written against an entity type the request does not actually carry. The decision is the same word, and the empty array is the only thing that distinguishes them.

Fix

Log determiningPolicies with every DENY, and alert differently on an empty one. "Denied by policy X" and "denied because nothing matched" belong in different queues.

2A policy can fail to evaluate and the call still succeeds

“If the action is successful, the service sends back an HTTP 200 response.” And among the fields it sends back:

“errors — Errors that occurred while making an authorization decision, for example, a policy references an Entity or entity Attribute that does not exist in the slice.”

So errors are not a failure mode of the call; they are a field in a successful response. A permit policy that references an attribute your request did not supply produces an entry there, does not contribute to the decision, and the caller gets a perfectly well-formed DENY.

The practical shape: somebody adds an attribute condition to a permit policy, the application is not updated to pass that attribute, and access quietly stops working — with a 200, a DENY, and an explanation sitting in a field the call site has to go looking for.

Fix

Treat a non-empty errors array as an alarm condition, not as diagnostics. It means your policies and your request shape have drifted apart, and the symptom is denial rather than error.

3The identifier in a policy is a string, and strings get reused

This is AWS's own warning and it deserves quoting whole:

“if user jane leaves the company, and you later let someone else use the name jane, then that new user automatically gets access to everything granted by policies that still reference User::"jane". Cedar can't distinguish between the new user and the old.”

A policy is durable and an identifier is just a name in it. Hence the instruction: “Always use identifiers that are guaranteed unique and never reused”, with UUIDs “strongly” recommended — and note the scope, because it is easy to read this as an identity problem only: “This applies to both principal and resource identifiers.”

A recycled document reference inherits the permissions written for whatever previously held that reference, with no change to any policy.

Fix

UUIDs for principals and resources, with AWS's own readability remedy: follow the UUID with a // comment naming the entity, so the policy is still legible to a human.

Architecture

A request you assemble, policies evaluated independently, and a response with three fields that most callers reduce to one.

Diagram: what an Amazon Verified Permissions authorization response actually contains, what the policy store schema does and does not constrain, and what an identifier inside a Cedar policy refers to over time. Verified Permissions currently uses Cedar version 4.7. For the IsAuthorized operation the caller must submit principal, action, resource, context and entities elements, and Verified Permissions evaluates the request against all policies in the requested policy store that apply to the entities in the request. A policy is a statement that either permits or forbids a principal to take one or more actions on a resource, and each policy is evaluated independently of every other policy. A successful call returns HTTP 200 and three response elements that matter. The decision element indicates whether the request should be allowed or denied, with valid values ALLOW and DENY. The determiningPolicies element is the list of determining policies used to make the decision, and it distinguishes three situations that all produce the same decision word: where two matching policies conflict, one a forbid and one a permit, the forbid policy will be the determining policy, meaning somebody wrote a rule against the request; where multiple matching permit policies exist there would be multiple determining policies; and in the case that no policies match, and hence the response is DENY, there would be no determining policies, meaning nobody granted access and nobody forbade it either. An empty determining-policies array is therefore the only thing distinguishing a default deny from an explicit forbid, and the two are different incidents belonging in different queues. The errors element carries errors that occurred while making an authorization decision, for example a policy referencing an Entity or entity Attribute that does not exist in the slice; because errors arrive inside a successful 200 response rather than as a call failure, a permit policy that references an attribute the request did not supply produces an error entry, does not contribute to the decision, and the caller receives a well-formed DENY, so a non-empty errors array should be treated as an alarm rather than as diagnostics. On the schema, a schema is a declaration of the structure of the entity types supported by the application and the actions the application may provide in authorization requests, and its use in Verified Permissions is optional though highly recommended for production software. Policy validation mode controls whether policy changes are validated against the schema: when validation is turned on, all attempts to create or update a policy or policy template are validated against the schema and Verified Permissions rejects the request attempt if validation fails, and if validation is activated then all new policies must conform with the schema. The console offers Strict, which is recommended, or Off, and turning it off requires typing the word confirm to acknowledge that updates to policies will no longer be validated against the schema, while the same change can be made through the UpdatePolicyStore operation by specifying a different value for the ValidationSettings parameter. AWS recommends leaving validation off during application development and turning it on for testing and leaving it on in production. The consequence, and a correction to what the preceding post promised, is that the schema constrains policy authoring at write time rather than constraining authorization at decision time: it is optional, it is gated by a mode that AWS suggests starting in the off position, it applies to new policies rather than retroactively revalidating those already stored, and it is not consulted when a decision is made. On identifiers, AWS warns that if a user named jane leaves the company and someone else is later allowed to use the name jane, the new user automatically gets access to everything granted by policies that still reference User colon colon jane, because Cedar cannot distinguish between the new user and the old; AWS therefore strongly recommends universally unique identifiers for all principal and resource identifiers, identifiers that are guaranteed unique and never reused, and notes that this applies to both principal and resource identifiers, so a recycled document reference inherits the permissions written for whatever previously held it. AWS also advises against including personally identifying, confidential or sensitive information in these identifiers because they appear in log entries shared in AWS CloudTrail trails, and suggests following a UUID with a double-slash comment naming the entity to keep policies legible. Finally, the token-based operations tie this post to its predecessor: IsAuthorizedWithToken generates an authorization request from user data in JSON web tokens and populates all attributes to the principal from the claims in the user's ID or access token, and group or user principal type information cannot be included in such a request, so all principal data must be populated into the JWT provided, which means the authorization decision inherits whatever the token asserted when it was issued. BatchIsAuthorized groups requests into a single batch operation that minimizes quota usage and returns decisions for each of up to 30 complex nested actions.
Three fields come back. Reducing them to one is where the information is lost.

What you have to assemble, and what gets evaluated

“You must submit principal, action, resource, context, and entities elements”, and then “Verified Permissions evaluates your request against all policies in the requested policy store that apply to the entities in the request”.

The word carrying the weight is entities — the slice of your application's data that you pass in so Cedar has something to reason over. Authorisation quality is bounded by what you chose to send, which is a different failure surface from IAM, where the evaluator already knows the resource. And the errors field exists precisely because a policy can reference something the slice does not contain.

Evaluation itself is simple and worth stating: “A policy is a statement that either permits or forbids a principal to take one or more actions on a resource. Each policy is evaluated independently of every other policy.” No ordering, no precedence numbers, no inheritance. Independence is what makes the combination rules above — forbid beats permit, multiple permits all count — the whole of the arbitration.

The response, as three fields

FieldWhat it tells youWhat is lost by ignoring it
decision “ALLOW | DENY” Nothing — but it is the whole of what most call sites keep
determiningPolicies Which policy decided, if any did Whether a DENY was an explicit forbid or nobody permitting
errors Policies that could not be evaluated against this slice That a policy meant to grant access did not take part

Read as a set, those three answer "why" as well as "whether", which is unusual for an authorisation API and is the reason this post exists. Read as one boolean, they answer only the easy half.

The token path makes this post's predecessor load-bearing

“The IsAuthorizedWithToken operation generates an authorization request from user data in JSON web tokens (JWTs)… Verified Permissions populates all attributes to the principal in your request from the claims in users' ID or access tokens.”

And it is not optional which data goes where: “You can't include information about group or user principal types in an IsAuthorizedWithToken request. You must populate all principal data to the JWT that you provide.”

#78 established that a token's claims are a snapshot fixed at issuance β€” cognito:groups included. So on this path the authorisation decision is made from attributes that were true when the token was signed, and the staleness window is the token lifetime. Fine-grained authorisation does not shorten it; it inherits it.

What the schema actually does

“A schema is a declaration of the structure of the entity types supported by your application, and the actions your application may provide in authorization requests.”

Then three qualifications, each of which narrows it:

AWS saysSo the schema…
“The use of schemas in Verified Permissions is optional”…may not exist at all
“control whether policy changes are validated against the schema”, Strict or Off…does nothing unless a mode is set
“all attempts to create or update a policy or policy template are validated”…is a check on writes
“then all new policies must conform with the schema”…does not revalidate what is already stored

That last row is the one to sit with, and it is the same shape as #76's unchanged objects never being re-analysed and #49's settings applying from the next cycle. Turning validation on secures the next policy you write. It makes no claim about the ones already there.

And the default position is deliberately permissive: “we recommend leaving validation off while you're developing your application and turning it on for testing and leaving it on while your application is in production”. Sensible advice, and it means the policies written during the period when the schema was not enforced are the ones that have not been checked against it — and will not be until something edits them.

Why This Architecture Holds Up

The provenance fields are a genuinely good design

Most authorisation systems give you a boolean and leave you to reconstruct the reasoning from logs. Returning determiningPolicies inline means "why was this denied" is answerable at the call site, in the same response, without a second query. For debugging a policy set, that is worth a great deal.

It also makes a specific class of audit question answerable: a permit decision with “multiple determining policies” is a request that more than one rule allowed, which is usually fine and occasionally a sign that a broad policy is doing work a narrow one was supposed to do.

Turning validation off has an interface asymmetry worth knowing

In the console, disabling validation requires an explicit typed acknowledgement: “Type confirm to confirm that updates to policies will no longer be validated against your schema.”

Through the API it is a parameter: “You can change the validation mode for a policy store by using the UpdatePolicyStore operation and specifying a different value for the ValidationSettings parameter.”

The guardrail lives in one interface. That is a common and defensible pattern — a human clicking needs the speed bump, automation needs to be scriptable — and it means the control you actually want is a Service Control Policy or a Config rule on UpdatePolicyStore, not the console prompt. The console prompt protects against a slip; it does not protect against a pipeline.

Identifiers travel into CloudTrail, which constrains how you name things

“Do not include personally identifying, confidential, or sensitive information as part of the unique identifier for your principals or resources. These identifiers are included in log entries shared in AWS CloudTrail trails.”

Two instructions converge here and they reinforce each other. Use UUIDs because names get reused; use UUIDs also because the identifier ends up in an audit log with a different access policy and a different retention period from your application database. An email address as a principal identifier fails both tests at once.

This is the same concern as #50, where project names in cost allocation tags travel into finance reports. An identifier chosen for one system's convenience is read by systems you did not have in mind.

Policy names are a usability feature with a uniqueness rule

“Policy names must be unique for all policies within the policy store and prefixed with name/… You can use a policy name in place of the policy ID in control plane operations that accept a policyId parameter.”

Useful, because a policy ID in an incident channel is unreadable. Worth pairing with the identifier warning though: a policy name is required to be unique within the store, while an entity identifier has no such guarantee — uniqueness there is entirely your discipline. The system enforces it for the thing it owns and asks you to enforce it for the thing you own.

Batch is about quota, and AWS says so

Of BatchIsAuthorized, AWS writes that it “groups requests into a single batch operation that minimizes quota usage and returns authorization decisions for each of up to 30 complex nested actions”.

Which tells you the intended UX pattern: deciding which of thirty buttons to render is one call, not thirty. It also sets the ceiling — a page with more than thirty independently authorised elements needs either several calls or a coarser authorisation model, and that is a design constraint worth knowing before the screen is built rather than after.

Key Architecture Decisions

Decision Choice Reasoning
What to log on a DENY decision and determiningPolicies An empty list is the only signal that nothing matched rather than something forbade.
Handling the errors array Alarm on non-empty It arrives with a 200 and a decision; a policy that errored did not participate.
Principal and resource identifiers UUIDs, never reused, with a // comment “Cedar can't distinguish between the new user and the old”, and it applies to resources too.
What must not be in an identifier Anything personal or confidential They appear “in log entries shared in AWS CloudTrail trails”.
Schema Write one, and know it is optional “optional, but… highly recommended for production software”.
Validation mode in production Strict AWS's own recommendation, and without it the schema constrains nothing.
When switching validation on Re-check the policies written before It applies to “all new policies” β€” existing ones are not revalidated.
Protecting validation mode Guard UpdatePolicyStore, not the console The typed confirmation exists only in the console; the API takes a parameter.
Token-based authorisation Accept that staleness equals token lifetime Principal attributes come “from the claims”, and all principal data must be in the JWT.
UX permission checks Batch them Up to 30 per call, and AWS frames it as minimising quota usage.
Pinning Cedar behaviour Record the version you tested against AWS names it — “Cedar version 4.7” — so it is a moving dependency.

The audit worth running

Grep your policy store for quoted identifiers that are not UUIDs. Every one is a name that could be reissued, and AWS's jane example is the exact failure. Then do the same for resource identifiers, which is the half people skip.

Then check the policy store's validation mode and compare it against the creation dates of your policies. Anything authored before Strict was switched on has never been checked against the schema, and nothing will check it until somebody edits it.

Closing Thought

Verified Permissions is unusually forthcoming. It returns the deciding policy, it returns evaluation errors, it tells you the Cedar version, and it warns about identifier reuse with a worked example involving a named employee. Almost everything in this post is a quotation rather than an inference.

What the service cannot do is make a caller read three fields instead of one. A DENY is immediately actionable as a boolean, and the two fields explaining it are optional in practice even though they are present in every response. So the most likely way to be wrong about your own authorisation system is to have discarded the part of the answer that says why.

Which rhymes with the five posts before it, and then diverges in a way worth naming. #74 through #77 were numbers answering adjacent questions. #78 was an action taking effect in the wrong system. This one is the opposite problem: the information is in the response, correct and complete, and gets dropped on the floor by the code receiving it. A gap in the documentation can be closed by reading. A gap in what your own code keeps can only be closed by changing the code.

Next in this series

Security & Identity — AWS Resource Access Manager: what a share actually grants once it is accepted, why a resource share and a resource policy are not interchangeable, and what happens to access when a share is deleted.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent