AWS Verified Permissions and Cedar Policy Language Complete Guide

First Published:
Last Updated:

AWS Verified Permissions (AVP) is the first AWS-managed authorization service designed not to gate access to AWS APIs but to be the externalized permission engine for your own applications. Its policy language, Cedar, is an open-source project that grew out of years of internal authorization work at AWS and was released under the Apache 2.0 license in 2023.

This guide is for engineers who already understand IAM and have hit its limits when modeling application-level authorization — per-document sharing in a SaaS product, multi-tenant isolation across customer accounts, hierarchical roles inside a single tenant, time-bounded grants, attribute-based rules driven by request context, and so on. If you have ever written if user.role == "admin" or user.id in document.shared_with: ... and watched that branch metastasize across your codebase, this article is for you.

You will leave with a working mental model of Cedar's entity-action-resource-context structure, the difference between Cedar and IAM policy language at the semantic level, the architecture of an AVP policy store, an end-to-end integration with Amazon Cognito as the identity source, and four reusable patterns (document permissions, multi-tenant SaaS, hierarchical roles, time-based grants) you can adapt to production.

It assumes you are comfortable with AWS IAM, JSON, and at least one server-side runtime that calls the AVP IsAuthorized API. Familiarity with OIDC tokens helps for the Cognito integration section but is not required. Deeper background on the workforce-identity layer that sits next to AVP is covered in AWS IAM Identity Center Complete Setup Guide. For Cognito-side federation patterns that feed an AVP identity source, see Amazon Cognito Federation Implementation Guide.

Table of Contents

1. What is AWS Verified Permissions?

AVP is a fully managed authorization service that evaluates Cedar policies against principals, actions, resources, and context that your application sends with each request. The service became generally available in June 2023, and the underlying Cedar language was open-sourced at the same time, which is unusual for AWS and matters for portability.

The mental shift to make here is that AVP does not authorize calls to AWS itself. It authorizes calls inside your application. When user alice opens a SaaS dashboard and clicks "Share this document with Bob," your application sends an IsAuthorized request to AVP, AVP evaluates the relevant Cedar policies, and returns Allow or Deny along with the determining policies. Your application then either performs the share or returns 403.

1.1 Why a Separate Service When IAM Exists

IAM authorizes principals against AWS service APIs. It was never designed to express "user alice can view document doc-42 only when document doc-42 belongs to tenant alpha and alice is in group editors of tenant alpha, between 09:00 and 18:00 in alice's local timezone." You can build that logic in application code, but you then own:
  • A bespoke policy data model that grows organically with every feature.
  • A policy evaluation engine that you have to test, audit, and prove correct.
  • A migration path every time the business adds a new dimension (regions, partner organizations, parent-child accounts).
  • An audit trail that auditors will eventually want, and that you will eventually have to retrofit.
AVP takes all of that off your plate by externalizing the policy language, the storage of policies, the evaluation engine, and the audit trail (via CloudTrail).

1.2 Where AVP Fits

LayerWhat authorizesMechanism
AWS API planeCalls from principals to AWS service APIsIAM identity-based / resource-based policies, SCPs, RCPs
Application network planeWhether the request can reach the application at allSecurity groups, NACLs, AWS WAF
Application authorization planeWhat an authenticated user is allowed to do inside the applicationAVP + Cedar (or homegrown logic)

In practice, AVP coexists with IAM. IAM still authorizes your Lambda function to call verifiedpermissions:IsAuthorized. Cedar policies inside AVP then authorize what the end user can do once that Lambda accepts their request.

1.3 Target Use Cases

AVP is a fit when authorization is part of your product's value, not part of its infrastructure. Concretely:
  • Multi-tenant SaaS where each tenant has its own roles, groups, and permission rules.
  • Document-style sharing where every resource has its own owner and a list of grantees with varying capabilities.
  • Hierarchical organizations with permissions that cascade by team, region, or business unit.
  • Fine-grained API authorization for an Amazon API Gateway HTTP API where you want a policy decision before invoking the backend.
  • Bring-your-own-IdP scenarios where users come from Cognito User Pools, an OIDC provider, or a corporate SAML directory.
It is not a fit (yet) when you need policies that operate on AWS resources directly or that need to be enforced by AWS services without an explicit IsAuthorized call in your application code.

2. Cedar Policy Language Primer

Cedar's core grammar is small and deliberately readable. Every policy is an effect (permit or forbid), a scope (which principal, action, and resource the policy applies to), and an optional when or unless condition expressed in a typed expression language. The policy engine evaluates all matching policies and combines their results with a clear precedence rule.

2.1 The Four Request Components

Every authorization request contains exactly four components:
  • Principal: who is asking. Always an entity reference like User::"alice".
  • Action: what they want to do. Always an action reference like Action::"viewDocument".
  • Resource: what they want to do it on. Always an entity reference like Document::"doc-42".
  • Context: extra information that is not part of an entity (e.g., source IP, MFA state, request time).
The engine receives the request plus the set of policies and the set of entities (a graph of typed objects with their attributes and parents relationships) and returns a decision.

2.2 Minimal Policy

permit (
    principal == User::"alice",
    action == Action::"viewDocument",
    resource == Document::"doc-42"
);
This is the simplest legal Cedar policy: it grants alice the ability to view document doc-42, and nothing else. Real policies are rarely this narrow because hard-coding identifiers does not scale, but it is useful for testing.

2.3 Scope Operators

Cedar's scope (principal, action, resource) supports three relational operators each:
  • == matches a single entity by exact identifier.
  • in matches any entity that is a descendant of a given parent in the entity hierarchy.
  • is matches any entity of a given type (introduced in Cedar 3.0).
permit (
    principal in Group::"editors",
    action in [Action::"viewDocument", Action::"editDocument"],
    resource is Document
)
when {
    resource.tenant == principal.tenant
};
This reads naturally: any principal in the editors group may view or edit any document, but only when the document belongs to the same tenant as the principal. Note the is operator on the resource scope (matches any entity of type Document), the list-of-actions form, and the use of dotted attribute access in the when clause.

2.4 when and unless Clauses

A when clause adds a positive condition: the policy applies only if the expression evaluates to true. An unless clause adds a negative condition: the policy is skipped if the expression evaluates to true.
permit (
    principal,
    action == Action::"deleteDocument",
    resource is Document
)
when {
    principal == resource.owner
}
unless {
    resource.locked == true
};
Multiple when and unless clauses can be chained; they combine with logical AND.

2.5 forbid Policies and the Deny Override

Cedar's evaluation rule is precise: if any forbid policy matches, the result is Deny. Otherwise, if at least one permit policy matches, the result is Allow. Otherwise, the result is the implicit Deny. This makes forbid the right tool for hard guardrails that should not be overridable by any positive grant.
forbid (
    principal,
    action,
    resource
)
when {
    context.mfa_present == false
};
This policy denies any request unless the caller proved MFA, regardless of what permit policies otherwise exist.

2.6 Built-in Operators

Cedar's expression language is small but expressive. The following operators all show up in real policies:

* You can sort the table by clicking on the column name.
OperatorMeaningExample
== / !=Value equalityprincipal == User::"alice"
< <= > >=Numeric / datetime comparisoncontext.amount < 1000
&& || !Boolean logica && (b || !c)
inEntity hierarchy membershipprincipal in Group::"editors"
isEntity type check (Cedar 3.0+)resource is Document
is ... in ...Combined type + hierarchyprincipal is User in Tenant::"alpha"
hasAttribute presence testprincipal has department
likeString wildcard matchprincipal.email like "*@example.com"
.contains / .containsAll / .containsAnySet membership testsprincipal.roles.contains("admin")

The has operator deserves its own line: Cedar attributes can be marked optional in the schema, and principal has email returns true only when the attribute is actually present on this entity, avoiding evaluation errors on access.

Beyond these core operators, Cedar ships two extension types for domains where naive string or number handling would be fragile: decimal for fixed-point arithmetic (decimal("0.01").lessThan(decimal("0.02"))) and ipaddr for IP and CIDR matching (context.source_ip.isInRange(ip("10.0.0.0/8"))). You declare a field as { "type": "Extension", "name": "ipaddr" } or { "type": "Extension", "name": "decimal" } in the schema, and the validator then ensures that policies invoke only the corresponding extension methods on it. The time-based grants pattern later in this guide uses the ipaddr extension for network filtering.

2.7 Entities and the Entity Hierarchy

Entities are typed objects with attributes and a parents list. The parents list is what makes the in operator work: principal in Group::"editors" returns true when the editors group is a (transitive) parent of the principal entity.

A common shape for a document-permissions app:
[
    {
        "uid": { "type": "User", "id": "alice" },
        "attrs": { "email": "alice@example.com", "department": "engineering" },
        "parents": [
            { "type": "Group", "id": "editors" },
            { "type": "Tenant", "id": "alpha" }
        ]
    },
    {
        "uid": { "type": "Document", "id": "doc-42" },
        "attrs": { "owner": { "type": "User", "id": "alice" }, "locked": false },
        "parents": [
            { "type": "Folder", "id": "f-design" },
            { "type": "Tenant", "id": "alpha" }
        ]
    }
]
Cedar Entity Hierarchy for a Document Sharing App
Cedar Entity Hierarchy for a Document Sharing App
The application sends this slice of the entity graph along with the request, or AVP fetches it from the configured identity source. The parents array is what lets the engine answer Document::"doc-42" in Tenant::"alpha" with a single positive lookup.

2.8 Policy Templates

A policy template is a Cedar policy with parameter placeholders (?principal or ?resource). The template is stored in AVP, and you create a template-linked policy by binding concrete values for the placeholders.
permit (
    principal == ?principal,
    action == Action::"viewDocument",
    resource == ?resource
);
When alice shares doc-42 with bob, your application calls CreatePolicy with this template ID and the bindings ?principal = User::"bob", ?resource = Document::"doc-42". The resulting policy is identical in effect to writing the inline version by hand, but it is reusable, indexable, and revocable as a single unit.

3. Cedar vs IAM Policy Language

Cedar and IAM JSON policies both authorize requests, but they target different layers and use different evaluation semantics. Engineers who try to apply IAM intuition to Cedar (or vice versa) routinely write policies that look correct but do the wrong thing.

* You can sort the table by clicking on the column name.
DimensionIAM Policy LanguageCedar Policy Language
Primary targetAWS service API callsApplication-defined actions
Document formatJSONCedar (purpose-built syntax)
Open-sourceNoYes (Apache 2.0, cedarpolicy.com)
Subject (principal)IAM principal (User, Role, Federated identity)Any application entity (User, Group, Tenant, ...)
Resource modelARNsApplication entity hierarchy with typed attributes and parents
Condition languageCondition blocks with operators per typeTyped expression language with when / unless
HierarchyIndirect (account, OU, organization)First-class via parents and in
Type systemImplicitSchema-validated entities, actions, and context
Evaluation ruleExplicit Deny wins, then Allow, else Denyforbid wins, then permit, else implicit Deny
Storage locationIAM (per-principal or per-resource)AVP policy store (one logical bag of policies per app)
AuditCloudTrail (IAM and STS events)CloudTrail (AVP API events)

A few semantic differences are worth calling out explicitly.

3.1 Forbid vs Explicit Deny

IAM and Cedar both implement a "deny-overrides-allow" model, but Cedar's forbid is more expressive because it can use the full condition language without having to fit inside a Condition block tied to a single statement. A guardrail like "no destructive action without MFA" is a one-line forbid in Cedar; in IAM, it usually requires per-service condition keys plus an explicit deny clause replicated across policies.

3.2 Entity Hierarchy vs ARN Matching

IAM authorizes resources by ARN, and hierarchy is mostly cosmetic: arn:aws:s3:::my-bucket/folder/key looks hierarchical but the policy engine only does prefix and wildcard matching. Cedar authorizes resources by typed entity, and hierarchy is structural: resource in Tenant::"alpha" walks the parents graph and returns true regardless of how deeply nested the resource is. This makes multi-tenant isolation rules dramatically shorter and harder to subtly break.

3.3 Context vs Condition Keys

IAM exposes a fixed set of condition keys per service (e.g., aws:SourceIp, s3:RequestObjectTag/...). Cedar exposes whatever you put in the context object on each request, validated against the schema. If you want to authorize on "shipment value in USD," you simply add a numeric value_usd to the context and reference it in a when clause. There is no analogue in IAM short of stuffing the value into a request tag or rewriting the service.

The takeaway is that Cedar's semantics are a strict superset of IAM's at the application layer. The cost is that you have to be explicit about your entity model in a schema, which we cover next.

4. Architecture Patterns

A production AVP deployment has five moving parts. Understanding which part owns which decision is the difference between a clean system and a sprawling one.
AWS Verified Permissions Architecture
AWS Verified Permissions Architecture

4.1 Policy Store

The policy store is the top-level container. Everything else (policies, templates, schema, identity source) lives inside one policy store. A typical mapping is one policy store per logical application, not per environment: you reuse the same policy store schema across dev / staging / prod by creating one policy store per environment but using IaC to keep them aligned.

The store has a validation mode set at creation time:
  • OFF: no schema validation. Useful for early prototyping; you should not ship to production with this.
  • STRICT: every policy is validated against the schema before it can be saved.

4.2 Schema

The schema is a JSON document that declares entity types, action types, the attributes each carries, and the request context shape. It is what gives Cedar its static guarantees: a policy that references principal.department when the schema says User has no department attribute will fail validation at save time, not at evaluation time.

A minimal schema for a document-sharing app:
{
    "DocStore": {
        "entityTypes": {
            "User": {
                "shape": {
                    "type": "Record",
                    "attributes": {
                        "email": { "type": "String" },
                        "department": { "type": "String", "required": false }
                    }
                },
                "memberOfTypes": ["Group", "Tenant"]
            },
            "Group": { "memberOfTypes": ["Tenant"] },
            "Tenant": {},
            "Document": {
                "shape": {
                    "type": "Record",
                    "attributes": {
                        "owner": { "type": "Entity", "name": "User" },
                        "locked": { "type": "Boolean" }
                    }
                },
                "memberOfTypes": ["Folder", "Tenant"]
            },
            "Folder": { "memberOfTypes": ["Folder", "Tenant"] }
        },
        "actions": {
            "viewDocument": {
                "appliesTo": {
                    "principalTypes": ["User"],
                    "resourceTypes": ["Document"],
                    "context": {
                        "type": "Record",
                        "attributes": {
                            "mfa_present": { "type": "Boolean" }
                        }
                    }
                }
            },
            "editDocument": {
                "appliesTo": { "principalTypes": ["User"], "resourceTypes": ["Document"] }
            },
            "deleteDocument": {
                "appliesTo": { "principalTypes": ["User"], "resourceTypes": ["Document"] }
            }
        }
    }
}
DocStore here is the namespace; it groups the types so you can have multiple disjoint type universes inside one policy store. memberOfTypes declares the legal parent types for the in operator. The appliesTo block of each action declares which principal and resource types may use this action, plus the optional context shape for that action.

Namespace and policy references: when a schema declares a namespace, every reference in a policy must be fully qualified. The type User defined above is referenced from a policy as DocStore::User, an action as DocStore::Action::"viewDocument", and so on. The short forms User, Action used in the Cedar primer earlier in this article were schema-less language examples; in a namespaced policy store like DocStore, AVP rejects them at STRICT validation time. The policy snippets in the following sections use the fully qualified form.

4.3 Policies

A policy is a Cedar text document. Two kinds of policies live in a policy store:
  • Static policies, written directly in Cedar source.
  • Template-linked policies, materialized from a template by binding placeholders.
The runtime treats them identically at evaluation. The split is purely for management.

4.4 Policy Templates

Templates carry the parameterized form of a policy and allow you to create a large number of similar policies cheaply. The canonical use case is per-resource sharing: one template per "kind of share," and one template-linked policy per share. When the share is revoked, you delete the template-linked policy. The template itself stays in place.

The number of template-linked policies you can store is much larger than the inline policy quota, which makes templates the only sensible mechanism for any application where users grant access dynamically (sharing a document, inviting a collaborator, etc.).

4.5 Identity Source

The identity source is what AVP needs to know about your users at evaluation time. Two modes are supported:
  • Amazon Cognito User Pool. AVP parses Cognito ID tokens (or access tokens) and turns the claims into a User entity. When groupConfiguration.groupEntityType is set on the identity source, each value in the cognito:groups claim is materialized as a parent entity of the configured type (see §5.4).
  • OIDC identity source (introduced after the Cognito-only era). Any OIDC provider whose tokens you can validate is acceptable; you tell AVP which claim is the principal ID, which claims are groups, and so on.
If you do not configure an identity source, you must construct the User entity manually on every IsAuthorized call. With an identity source configured, you instead use IsAuthorizedWithToken and pass the raw JWT.

4.6 How a Request Flows

A typical request flow looks like this:
AWS Verified Permissions Request Flow
AWS Verified Permissions Request Flow
  1. Browser sends API call with bearer token to API Gateway / ALB / direct Lambda.
  2. Backend validates the token (signature, expiry, issuer).
  3. Backend constructs the resource entity (Document::"doc-42") and any context (mfa_present, source_ip, hour_utc).
  4. Backend calls AVP IsAuthorizedWithToken with: { token, action, resource, context, entities }.
  5. AVP parses the token into a User entity and any group memberships.
  6. AVP evaluates all matching policies against the request.
  7. AVP returns { decision: Allow|Deny, determiningPolicies: [...] }.
  8. Backend either performs the action or returns 403.
The determining policies in the response are essential for debugging and auditing: they tell you exactly which policy clinched the decision, which is how you investigate "why did the access I expected to allow get denied?" without re-running the entire policy set in your head.

5. Integration with Amazon Cognito

Cognito User Pools are the most common identity source for AVP-backed applications because both products live in the same AWS account and the wiring is purpose-built. The integration takes care of token verification, claim extraction, and group mapping. For background on the broader Cognito timeline, see AWS History and Timeline of Amazon Cognito.

5.1 What AVP Does with a Cognito Token

When you call IsAuthorizedWithToken, AVP:
  1. Verifies the token signature against the User Pool's JWKS.
  2. Verifies iss matches the configured Cognito User Pool ARN.
  3. Verifies aud (for ID tokens) or client_id (for access tokens) matches an allowed client ID.
  4. Verifies exp and nbf.
  5. Extracts the sub claim and treats it as the principal entity ID, using the type declared in principalEntityType on the identity source.
  6. Extracts the cognito:groups claim (if present) and, when groupConfiguration.groupEntityType is configured, materializes each value as a parent entity of that type.
  7. Exposes the other claims as attributes of the principal entity, validated against the schema.

5.2 Mapping Schema

You declare which Cognito claims become which entity attributes in the policy store's schema. For example, the Cognito schema attribute custom:tenant_id can be mapped to a tenant attribute of type Entity (parent type Tenant), so that a policy can say:
permit (
    principal,
    action in [DocStore::Action::"viewDocument", DocStore::Action::"editDocument"],
    resource is DocStore::Document
)
when {
    principal.tenant == resource.tenant
};
The principal.tenant value is populated automatically from the token at evaluation time.

5.3 Token Type: ID Token vs Access Token

Cognito issues both ID tokens and access tokens. For most application authorization scenarios you want the ID token, because it carries user attributes and group memberships. Access tokens are appropriate when authorizing machine-to-machine flows that only need scopes and the subject identifier.

AVP supports both, but you must declare in the identity source which token type a given client will send: ID, access, or "both" with explicit per-call selection.

5.4 Configuring an Identity Source (CDK Example)

import * as cdk from "aws-cdk-lib";
import * as verifiedpermissions from "aws-cdk-lib/aws-verifiedpermissions";

const policyStore = new verifiedpermissions.CfnPolicyStore(this, "DocStore", {
    description: "Document sharing policy store",
    validationSettings: { mode: "STRICT" },
    schema: {
        cedarJson: JSON.stringify(schemaJson),
    },
});

new verifiedpermissions.CfnIdentitySource(this, "CognitoSource", {
    policyStoreId: policyStore.attrPolicyStoreId,
    configuration: {
        cognitoUserPoolConfiguration: {
            userPoolArn: userPool.userPoolArn,
            clientIds: [userPoolClient.userPoolClientId],
            groupConfiguration: {
                groupEntityType: "DocStore::Group",
            },
        },
    },
    principalEntityType: "DocStore::User",
});
The principalEntityType controls what entity type the sub is turned into. The groupEntityType controls what entity type each Cognito group is turned into. Both default to a generic type if omitted, but specifying them up front avoids retrofitting schema later.

5.5 Bringing Your Own OIDC Provider

If you are on Auth0, Okta, Microsoft Entra ID, or a homegrown OIDC service, configure an OIDC identity source instead of Cognito:
new verifiedpermissions.CfnIdentitySource(this, "OidcSource", {
    policyStoreId: policyStore.attrPolicyStoreId,
    configuration: {
        openIdConnectConfiguration: {
            issuer: "https://login.example.com/",
            tokenSelection: {
                identityTokenOnly: {
                    principalIdClaim: "sub",
                    clientIds: ["application-client-id"],
                },
            },
            entityIdPrefix: "ExampleIdP",
            groupConfiguration: {
                groupClaim: "groups",
                groupEntityType: "DocStore::Group",
            },
        },
    },
    principalEntityType: "DocStore::User",
});
The shape is almost identical to the Cognito case; only the configuration block differs. By mapping both Cognito and OIDC groups to the same DocStore::Group type, policies can check group memberships uniformly via principal in DocStore::Group::"<name>" regardless of which identity source served the token; the group claim values are materialized as parent entities of the principal, exactly like Cognito groups. The fuller picture of the Cognito federation surface that feeds these tokens is in Amazon Cognito Federation Implementation Guide.

6. Implementation Walkthrough

This section walks through the full lifecycle of an AVP integration. The runnable code uses Python on the Lambda side, AWS CDK (TypeScript) for the infrastructure, and Cedar text for policies. The same flow applies if you choose CloudFormation, Terraform, or pure SDK calls; the steps are the same and the API names are identical.

6.1 Step 1: Create the Policy Store

import * as cdk from "aws-cdk-lib";
import * as verifiedpermissions from "aws-cdk-lib/aws-verifiedpermissions";

const policyStore = new verifiedpermissions.CfnPolicyStore(this, "DocStore", {
    description: "Document sharing policy store",
    validationSettings: { mode: "STRICT" },
    schema: {
        cedarJson: JSON.stringify(require("./schema.json")),
    },
});
Setting validationSettings.mode to STRICT is the only acceptable choice for production. The cost is that you cannot save a policy that references an undeclared attribute or action; the benefit is that bad policies fail at deploy time, not at runtime.

6.2 Step 2: Define the Schema

schema.json declares the types your application uses:
{
    "DocStore": {
        "entityTypes": {
            "User": {
                "shape": {
                    "type": "Record",
                    "attributes": {
                        "email": { "type": "String" },
                        "tenant": { "type": "Entity", "name": "Tenant" }
                    }
                },
                "memberOfTypes": ["Group", "Tenant"]
            },
            "Group": { "memberOfTypes": ["Tenant"] },
            "Tenant": {},
            "Document": {
                "shape": {
                    "type": "Record",
                    "attributes": {
                        "owner": { "type": "Entity", "name": "User" },
                        "tenant": { "type": "Entity", "name": "Tenant" },
                        "locked": { "type": "Boolean" }
                    }
                },
                "memberOfTypes": ["Folder", "Tenant"]
            },
            "Folder": { "memberOfTypes": ["Folder", "Tenant"] }
        },
        "actions": {
            "viewDocument": {
                "appliesTo": {
                    "principalTypes": ["User"],
                    "resourceTypes": ["Document"]
                }
            },
            "editDocument": {
                "appliesTo": {
                    "principalTypes": ["User"],
                    "resourceTypes": ["Document"]
                }
            },
            "shareDocument": {
                "appliesTo": {
                    "principalTypes": ["User"],
                    "resourceTypes": ["Document"]
                }
            },
            "deleteDocument": {
                "appliesTo": {
                    "principalTypes": ["User"],
                    "resourceTypes": ["Document"]
                }
            }
        }
    }
}
The hierarchy here is intentional: every User and every Document belongs to exactly one Tenant, which is the structural enforcement of multi-tenant isolation.

6.3 Step 3: Write Static Policies

Static policies live in source control, deployed alongside the rest of your infrastructure. Two examples:
// p-tenant-isolation: any user may only act on resources in their own tenant.
permit (
    principal,
    action,
    resource
)
when {
    principal.tenant == resource.tenant
};
// p-owner-full: the owner of a document may do anything to it.
permit (
    principal,
    action in [DocStore::Action::"viewDocument", DocStore::Action::"editDocument", DocStore::Action::"shareDocument", DocStore::Action::"deleteDocument"],
    resource is DocStore::Document
)
when {
    resource.owner == principal
};
Notice that p-tenant-isolation is a permit, not a forbid. It says "permission may apply only when tenants match." Cross-tenant access does not get denied by this policy alone; it simply does not get granted, and falls through to the implicit deny. If you need an explicit forbid for cross-tenant traffic (a guardrail that no other policy can override), write one:
// p-tenant-guardrail: hard ban on cross-tenant access.
forbid (
    principal,
    action,
    resource
)
unless {
    principal.tenant == resource.tenant
};
Pick one of the two. The guardrail version is stricter and safer for SaaS where cross-tenant access would be a security incident.

6.4 Step 4: Create a Template for Ad Hoc Sharing

// t-share-document: alice grants bob the ability to view doc-42.
permit (
    principal == ?principal,
    action in [DocStore::Action::"viewDocument"],
    resource == ?resource
);
Deploy this template by ID. In the runtime, when alice clicks "share doc-42 with bob," your code calls CreatePolicy with:
import boto3

avp = boto3.client("verifiedpermissions")

avp.create_policy(
    policyStoreId=POLICY_STORE_ID,
    definition={
        "templateLinked": {
            "policyTemplateId": SHARE_TEMPLATE_ID,
            "principal": { "entityType": "DocStore::User", "entityId": "bob" },
            "resource":  { "entityType": "DocStore::Document", "entityId": "doc-42" },
        }
    },
)
When alice revokes the share, you delete the template-linked policy:
avp.delete_policy(policyStoreId=POLICY_STORE_ID, policyId=share_policy_id)

6.5 Step 5: Call IsAuthorizedWithToken from the Application

The application code, running in a Lambda function fronted by API Gateway:
import boto3
import json
import os

avp = boto3.client("verifiedpermissions")
POLICY_STORE_ID = os.environ["AVP_POLICY_STORE_ID"]
IDENTITY_SOURCE_ID = os.environ["AVP_IDENTITY_SOURCE_ID"]

def lambda_handler(event, context):
    token = event["headers"]["authorization"].removeprefix("Bearer ")
    document_id = event["pathParameters"]["documentId"]

    document = load_document(document_id)
    tenant_id = document["tenant_id"]
    owner_id = document["owner_id"]

    result = avp.is_authorized_with_token(
        policyStoreId=POLICY_STORE_ID,
        identityToken=token,
        action={
            "actionType": "DocStore::Action",
            "actionId": "viewDocument",
        },
        resource={
            "entityType": "DocStore::Document",
            "entityId": document_id,
        },
        entities={
            "entityList": [
                {
                    "identifier": {
                        "entityType": "DocStore::Document",
                        "entityId": document_id,
                    },
                    "attributes": {
                        "owner": {
                            "entityIdentifier": {
                                "entityType": "DocStore::User",
                                "entityId": owner_id,
                            }
                        },
                        "tenant": {
                            "entityIdentifier": {
                                "entityType": "DocStore::Tenant",
                                "entityId": tenant_id,
                            }
                        },
                        "locked": { "boolean": document.get("locked", False) },
                    },
                    "parents": [
                        {
                            "entityType": "DocStore::Tenant",
                            "entityId": tenant_id,
                        }
                    ],
                },
            ]
        },
    )

    if result["decision"] == "ALLOW":
        return {
            "statusCode": 200,
            "body": json.dumps(document_to_dto(document)),
        }
    return { "statusCode": 403, "body": "Forbidden" }
Two details deserve attention. First, you only pass the resource entity into entities, because AVP builds the principal entity from the token. Second, the resource's parents array is what enables resource in Tenant::"alpha" to evaluate correctly without doing a separate database lookup.

6.6 Step 6: Grant the Lambda the Right IAM Permission

import * as iam from "aws-cdk-lib/aws-iam";

lambdaFn.addToRolePolicy(new iam.PolicyStatement({
    actions: ["verifiedpermissions:IsAuthorizedWithToken"],
    resources: [policyStore.attrArn],
}));
The IsAuthorizedWithToken permission is scoped to a single policy store ARN, so this Lambda cannot accidentally evaluate against another team's store.

7. Common Patterns

The set of policies you need in production has more structure than it looks. Most applications can be assembled from four reusable patterns. Each pattern is independently useful, and they compose naturally.

7.1 Pattern 1: Document Permissions (Sharing)

The canonical model: every document has an owner who can do everything, and arbitrary users may receive specific verbs (view, edit) via per-share policies. Implementation requires:
  • One static policy granting the owner all actions on owned documents.
  • One template for each "kind of share" (one for view-only, one for edit, one for full collaboration).
  • A CreatePolicy call when the owner shares.
  • A DeletePolicy call when the owner unshares.
// Static: owner has full rights.
permit (
    principal,
    action in [DocStore::Action::"viewDocument", DocStore::Action::"editDocument", DocStore::Action::"shareDocument", DocStore::Action::"deleteDocument"],
    resource is DocStore::Document
)
when { resource.owner == principal };

// Template: view share.
permit (
    principal == ?principal,
    action in [DocStore::Action::"viewDocument"],
    resource == ?resource
);

// Template: edit share.
permit (
    principal == ?principal,
    action in [DocStore::Action::"viewDocument", DocStore::Action::"editDocument"],
    resource == ?resource
);
Each share creates one template-linked policy. Listing alice's accessible documents is a ListPoliciesForResource-style application query (or, more commonly, a denormalized index in your own database that mirrors what you wrote to AVP).

7.2 Pattern 2: Multi-Tenant SaaS

Strict tenant isolation is a guardrail, not a permission. Express it with forbid:
// Hard isolation: no action across tenants, regardless of any permit.
forbid (
    principal,
    action,
    resource
)
unless {
    resource is DocStore::Tenant ||
    principal.tenant == resource.tenant
};
The resource is DocStore::Tenant clause is there so that an action like DocStore::Action::"viewTenantSettings" whose resource is the Tenant itself does not get caught by the guardrail.

Combine with per-tenant role policies:
// Tenant admin: full control inside own tenant.
permit (
    principal in DocStore::Group::"tenant-admin",
    action,
    resource
)
when { principal.tenant == resource.tenant };

// Tenant member: read-only inside own tenant.
permit (
    principal in DocStore::Group::"tenant-member",
    action in [DocStore::Action::"viewDocument"],
    resource is DocStore::Document
)
when { principal.tenant == resource.tenant };
The two layers (guardrail + roles) are orthogonal, which makes them easy to audit independently.

7.3 Pattern 3: Hierarchical Roles

When the org chart matters (region → country → city, or company → BU → team), express the hierarchy in the entity graph and use in to walk it:
// Regional VP: full control on all documents in their region.
permit (
    principal,
    action,
    resource is DocStore::Document
)
when {
    principal has region &&
    resource in principal.region
};
resource in principal.region returns true if any ancestor of the resource is the principal's region entity. The schema models Document memberOfTypes ["Project", "Team", "City", "Country", "Region"], and each document's parents list includes its direct project (which is then a child of team, city, country, region). The Cedar engine handles the transitive walk.

7.4 Pattern 4: Time-Based / Context-Based Grants

Cedar's context is the right home for properties that change per request: time, IP, MFA state, multi-factor risk score, geography.
// View only during business hours, only from approved networks, only with MFA.
permit (
    principal,
    action == DocStore::Action::"viewDocument",
    resource is DocStore::Document
)
when {
    context.hour_utc >= 9 &&
    context.hour_utc <= 18 &&
    context.source_ip.isInRange(ip("10.0.0.0/8")) &&
    context.mfa_present == true
};
The application is responsible for filling context.hour_utc, context.source_ip, and context.mfa_present correctly. The schema declares source_ip with the Cedar ipaddr extension type so that isInRange and the ip() constructor work without falling back to fragile string-wildcard matching. The schema declares the context shape per action, so a policy that references context.country on an action whose context does not define country is a validation error.

7.5 Pattern Composition

Patterns compose at the policy store level. A real document-sharing SaaS combines all four:
  1. Hard tenant isolation (Pattern 2).
  2. Owner-has-everything (Pattern 1).
  3. Per-share grants via templates (Pattern 1).
  4. Tenant-admin and tenant-member role policies (Pattern 2).
  5. Context-based MFA guardrail for delete actions (Pattern 4).
The order does not matter; Cedar's evaluation rule (forbid wins, then permit, then implicit deny) is deterministic regardless of insertion order.

8. Operations

A working policy store is not finished. Three operational concerns deserve explicit thought before you reach production scale. Some of the lessons here echo the field debugging stories in AWS Postmortem Case Studies and Design Lessons: the audit trail you wish you had is the audit trail you have to build before the incident.

8.1 Policy Testing with the Cedar CLI

The Cedar CLI is the local equivalent of IsAuthorized. You can install it via cargo install cedar-policy-cli (or use the WASM-based playground at cedarpolicy.com).

A typical test runs three files:
cedar authorize \
    --policies policies.cedar \
    --schema schema.json \
    --entities entities.json \
    --request-json request.json
request.json shapes the principal, action, resource, and context. entities.json is the JSON form of the entity graph (same format AVP accepts in IsAuthorized). The CLI returns Allow or Deny plus determining policies, exactly like AVP.

Wire this into CI: a directory of tests/ containing one (request, entities, expected_decision) tuple per case, and a small driver script that iterates and fails on mismatches. Every policy change goes through this gate before being deployed to AVP. AVP also exposes a IsAuthorized batch API you can use for the same purpose, but local testing is faster and works without AWS credentials.

8.2 Schema Migration

Schema is versioned implicitly: any call to PutSchema overwrites the previous schema. The catch is that in STRICT validation mode, AVP re-validates every existing policy against the new schema. If your new schema removes an attribute that an existing policy still references, the PutSchema call fails.

The migration recipe:
  1. Plan the schema change as additive first (new attributes, new actions, new entity types).
  2. Apply the additive schema change. Existing policies are unaffected.
  3. Update policies to use the new attributes. Validate locally with the Cedar CLI.
  4. Deploy the updated policies.
  5. (Optional) Apply a second schema change that removes the now-unused attributes.
For breaking changes that cannot be sliced this way (e.g., changing the type of an existing attribute), create a parallel policy store, mirror policies into it, point a subset of traffic at it, validate, then cut over. AVP does not have a built-in shadow-evaluation mode, so the parallel-store approach is the cleanest path.

8.3 Audit and Observability

Every AVP API call is recorded in CloudTrail with the policy store ID, the API name, and the caller identity. For application-level audit, the events worth wiring into your data lake are:
  • CreatePolicy / UpdatePolicy / DeletePolicy: changes to authorization rules.
  • CreatePolicyTemplate / UpdatePolicyTemplate / DeletePolicyTemplate: changes to templates.
  • PutSchema: schema changes.
  • IsAuthorized / IsAuthorizedWithToken: every authorization decision (this is noisy; consider sampling).
The IsAuthorized response carries determiningPolicies in addition to the decision. Log this in your application's structured log alongside the request ID. When a security review asks "who allowed this access on Tuesday at 14:07?", the answer is a single grep against the determining-policy IDs.

8.4 Cost-Aware Tips (Without Dwelling on Price)

Three operational habits keep authorization cheap and fast:
  • Batch authorizations where the same principal acts on many resources (e.g., loading a folder of documents). AVP has a BatchIsAuthorized API designed for this.
  • Cache entities you compute server-side when the application emits many requests in a short window. The same User entity does not need to be reconstructed per request when nothing has changed.
  • Push static rules into static policies (one-time write) and dynamic rules into template-linked policies (per-share, parametrized). Inline-policy churn is the enemy of clean audit trails.
The wider network-perimeter design that sits in front of any AVP-backed application is covered in AWS PrivateLink and VPC Endpoints Complete Guide; both layers (network admission and application authorization) should be wired into the same audit pipeline.

9. Frequently Asked Questions

Do I need AVP if my application is single-tenant?

If "authorization" in your application is one or two role checks (if user.is_admin: ...), you do not need a separate service. AVP starts paying off when your policy set is large enough that a homegrown rule engine is becoming a maintenance burden, or when the audit trail of "who allowed what" becomes a compliance question. Multi-tenant SaaS, customer-managed permissions, and any application with delegated administration are textbook fits.

Can I use Cedar without AVP?

Yes. Cedar is open-source under Apache 2.0 and has bindings for Rust (the reference implementation) and Java, with community ports for other languages. You can evaluate Cedar policies locally, embed them in your service, and use AVP only for management. The trade-off is that you become responsible for storing policies, distributing them, and auditing changes; AVP exists to take that off your plate.

Cedar vs OPA / Rego?

Both are policy-as-code languages, both have an evaluation engine, both are open-source. Cedar's distinguishing features are its first-class entity hierarchy, its small purpose-built grammar (intentionally not Turing-complete), and its formal proof of analyzability: by design, Cedar policies can be statically analyzed for properties like "does this policy ever grant more access than this other policy?" Rego is more general but harder to analyze for those properties. If your authorization domain has entities and hierarchies, Cedar's data model is more direct. If your domain is more like "evaluate any boolean predicate over arbitrary JSON," Rego is more flexible.

Does AVP support resource-based policies like S3 bucket policies?

Indirectly. AVP policies are stored centrally in a policy store; they are not attached to individual resources. You can model the equivalent (e.g., "Document::doc-42's policy is...") by writing policies whose resource scope is bound to a specific document, but the storage location is always the policy store. This is a deliberate design choice: it makes the policy set discoverable and auditable from one place.

How does AVP handle high-cardinality entities?

Two scaling techniques. First, use templates: instead of one inline policy per share, one template plus many template-linked policies. The template-linked count is much higher than the inline policy quota. Second, pass entities at request time only for the resources actually involved in the call. AVP does not require you to upload your entire entity graph; you upload the slice that the policies need.

Does Cedar support negation in scopes?

Not directly. You cannot write principal != User::"alice" in the scope, but you can put the equivalent in a when or unless clause. The scope is intentionally restricted to make policies indexable and analyzable.

Can I migrate from IAM custom authorizers to AVP?

Yes. The typical migration pattern is: keep IAM for AWS-API authorization, introduce AVP for application-level decisions previously baked into a Lambda authorizer or homegrown service. Run both in parallel for a release; log AVP's decision shadow-mode alongside the legacy decision; cut over when they agree on the test set you care about.

Is there a way to express ABAC purely with Cedar?

Yes. Cedar is essentially a typed ABAC engine. Attributes go on principals and resources, and conditions reference them in when clauses. The only RBAC ingredients are entity types and in, which give you groups and hierarchies. Most production policy sets are a mix: roles in the scope (principal in Group::"editors") plus attribute conditions in when (when { resource.tenant == principal.tenant }).

What happens to policies if I delete the policy store?

They are gone. Policy stores are not soft-deleted. Export your policies via the API (or back them up via IaC, which is the recommended pattern anyway) before deleting.

Can the application code see the Cedar AST?

No. AVP exposes the policy source as text and the decision as JSON; it does not expose the parsed AST. If you need AST-level access (e.g., for a policy visualizer), use the open-source Cedar SDK locally on the same policy text.

10. Summary

The mental model that survives contact with production AVP looks like this:
  • One policy store per application, with STRICT validation enabled from day one.
  • Schema-first design: declare your entities, their memberOfTypes, their attributes, and the actions before writing any policies.
  • Static policies for invariants (owner-has-all, tenant-isolation guardrail, no-MFA-no-delete).
  • Templates for dynamic shares, materialized into template-linked policies at runtime and revoked by deletion.
  • Identity source wired to Cognito (or OIDC) so you never re-derive the principal entity in application code.
  • IsAuthorizedWithToken from the backend with the relevant resource entity and context attached.
  • Cedar CLI in CI for policy regression tests, plus CloudTrail logging of every IsAuthorized decision for audit.
If you take only one thing from this guide, take this: Cedar's forbid is a sharper instrument than IAM's explicit deny. Use it deliberately for guardrails. And in is the operator that pays for everything else: every minute spent modeling your entity hierarchy correctly is a minute you do not have to spend writing nested conditions later.

The next steps in this series go deeper on the wider AWS identity surface: the IAM glossary (terms you will keep meeting), the IAM history and timeline (how the platform got here), and an anti-pattern catalog so you can see what good intentions look like when they go wrong. Cedar and AVP slot into that picture as the application-layer answer that the rest of IAM was never designed to give.

11. References


References:
Tech Blog with curated related content

Written by Hidekazu Konishi