---
title: Amazon Verified Permissions: Fine-Grained Authorization with Cedar for SaaS Applications
description: Amazon Verified Permissions externalizes application authorization logic using the Cedar policy language. Here's how to replace home-grown RBAC with a centralized, auditable policy store on AWS.
url: https://www.joinhorizon.co/blog/amazon-verified-permissions-cedar/
datePublished: 2025-11-24T00:00:00.000Z
dateModified: 2026-10-28T00:00:00.000Z
author: nash-ogden
category: Security & Compliance
tags: verified-permissions, cedar, authorization, iam, saas-security, aws
---

# Amazon Verified Permissions: Fine-Grained Authorization with Cedar for SaaS Applications

> Amazon Verified Permissions externalizes application authorization logic using the Cedar policy language. Here's how to replace home-grown RBAC with a centralized, auditable policy store on AWS.

import { Image } from 'astro:assets';

Most SaaS apps start with `users` / `roles` / `permissions` tables and end year two with dozens of roles, override tables, and authz checks sprinkled across handlers. As of **July 2026**, **Amazon Verified Permissions** (GA May 2023) + open-source **Cedar** remain the managed way to externalize that app-domain authorization: store policies, call **`IsAuthorized`** / **`BatchIsAuthorized`**, change access without redeploying handlers, and audit via CloudTrail.

**First-party benchmark (illustrative):** a multi-tenant document SaaS often needs a **2 to 4 week** shadow-mode cutover (inventory → Cedar → parallel decisions → route-by-route enforce). Latency for same-Region `IsAuthorized` is typically single-digit to low tens of ms. Measure in your Region.

**Reproduce this:** [AVP shadow-mode checklist](https://www.joinhorizon.co/examples/architecture-blog-2026/verified-permissions-cedar/avp-shadow-mode-checklist.md)

**Counter-case:** AVP does **not** replace Cognito authentication or IAM for AWS API calls. It answers only “can this user edit this document in this tenant?”

This guide covers Cedar, IsAuthorized, multi-tenant patterns, and RBAC migration.

## The Authorization Gap: Why IAM and Cognito Are Not Enough

IAM and Cognito each solve part of the authorization problem but leave a gap in the middle.

**Amazon Cognito** handles authentication: who is this user, are they authenticated, what groups do they belong to? Cognito User Pools issue JWTs containing the user's identity and group memberships. Cognito does not know anything about your application's resources, data, or the specific actions users are permitted to perform on specific objects.

**AWS IAM** handles authorization for AWS API calls: can this IAM principal call `s3:GetObject` on this S3 bucket? IAM policies are evaluated by the AWS control plane when an AWS SDK/API call is made. IAM cannot answer "can user alice edit document 123 that belongs to tenant acme". That is your application's domain, not AWS's control plane.

**The gap**: neither service can express the authorization logic "allow user alice to edit document 123, but only if alice is a member of the acme team and the document is in the draft state." This logic currently lives in your application code, and moving it out is what Verified Permissions enables.

| Concern                             | Amazon Cognito         | AWS IAM                 | Amazon Verified Permissions        |
| ----------------------------------- | ---------------------- | ----------------------- | ---------------------------------- |
| **Who are you?**                    | Yes (authentication)   | No                      | No                                 |
| **What AWS services can you call?** | No                     | Yes                     | No                                 |
| **What can you do in my app?**      | Coarse (group-based)   | No                      | Yes (fine-grained)                 |
| **Resource-specific authorization** | No                     | Via resource ARNs       | Yes (arbitrary resource hierarchy) |
| **Audit log of decisions**          | No                     | CloudTrail              | CloudTrail                         |
| **Change without deployment**       | No                     | Yes (IAM policy update) | Yes (Cedar policy update)          |
| **Multi-tenant data isolation**     | Via Cognito user pools | No                      | Yes (via resource hierarchy)       |

The intended architecture: Cognito authenticates the user and issues a JWT → your application extracts the identity → AVP IsAuthorized makes the authorization decision → your application enforces it.

## Cedar Policy Language: Principals, Actions, Resources, and Conditions

Cedar is an open-source policy language designed for fast, safe authorization evaluation. It is statically typed, always terminates (no infinite loops), and has a formal semantics that enables automated policy analysis. AWS Access Analyzer uses Cedar internally.

A Cedar policy has this structure:

```
effect(principal [condition], action [condition], resource [condition])
[when { conditions }]
[unless { conditions }];
```

The following builds a complete authorization model for a document management SaaS.

**Entity schema definition** (stored in the policy store):

```json
{
  "namespace": "DocApp",
  "entityTypes": {
    "User": {
      "shape": {
        "type": "Record",
        "attributes": {
          "department": { "type": "String" },
          "level": { "type": "Long" }
        }
      },
      "memberOfTypes": ["Team"]
    },
    "Team": {
      "shape": {
        "type": "Record",
        "attributes": {
          "tenantId": { "type": "String" }
        }
      }
    },
    "Document": {
      "shape": {
        "type": "Record",
        "attributes": {
          "ownerId": { "type": "String" },
          "tenantId": { "type": "String" },
          "status": { "type": "String" }
        }
      },
      "memberOfTypes": ["Folder"]
    },
    "Folder": {}
  },
  "actions": {
    "viewDocument": {
      "appliesTo": {
        "principalTypes": ["User"],
        "resourceTypes": ["Document"]
      }
    },
    "editDocument": {
      "appliesTo": {
        "principalTypes": ["User"],
        "resourceTypes": ["Document"]
      }
    },
    "deleteDocument": {
      "appliesTo": {
        "principalTypes": ["User"],
        "resourceTypes": ["Document"]
      }
    }
  }
}
```

**Policy 1: Document owner can perform all actions**

```plaintext
permit (
  principal,
  action in [
    DocApp::Action::"viewDocument",
    DocApp::Action::"editDocument",
    DocApp::Action::"deleteDocument"
  ],
  resource
)
when {
  resource.ownerId == principal.id
};
```

**Policy 2: Team member can view documents in their tenant**

```plaintext
permit (
  principal in DocApp::Team::"?team",
  action == DocApp::Action::"viewDocument",
  resource
)
when {
  resource.tenantId == principal.team.tenantId
};
```

**Policy 3: Admins can delete any document in their tenant**

```plaintext
permit (
  principal in DocApp::Team::"?team",
  action == DocApp::Action::"deleteDocument",
  resource
)
when {
  principal.level >= 5 &&
  resource.tenantId == principal.team.tenantId
};
```

**Policy 4: Forbid editing documents in published status (override)**

```plaintext
forbid (
  principal,
  action == DocApp::Action::"editDocument",
  resource
)
when {
  resource.status == "published"
};
```

Cedar evaluates `forbid` policies as overrides: if any `forbid` policy matches, the decision is Deny regardless of `permit` policies. This is explicit-deny-overrides semantics, which you need for rules like "no one can edit a published document, not even the owner."

## The IsAuthorized API: Runtime Authorization

![Architecture diagram of an application calling Verified Permissions IsAuthorized with a principal, action, and resource](../../assets/images/blog/amazon-verified-permissions-cedar-check.webp)

_Figure: IsAuthorized in the article. The application sends the principal, the action, and the resource. The answer is ALLOW or DENY, plus the policies that decided it. The sample policy store id is not shown. [Open the draw.io source](https://www.joinhorizon.co/examples/architecture-blog-2026/amazon-verified-permissions-cedar/check.drawio)._

Every authorization check in your application becomes a single API call:

```python
import boto3
import json

avp = boto3.client('verifiedpermissions')
POLICY_STORE_ID = 'AbCdEfGhIjKlMnOpQrSt'

def authorize(user_id: str, action: str, document_id: str, document_attrs: dict) -> bool:
    """
    Check if user_id can perform action on document_id.
    Returns True if allowed, False if denied.
    """
    response = avp.is_authorized(
        policyStoreId=POLICY_STORE_ID,
        principal={
            'entityType': 'DocApp::User',
            'entityId': user_id
        },
        action={
            'actionType': 'DocApp::Action',
            'actionId': action
        },
        resource={
            'entityType': 'DocApp::Document',
            'entityId': document_id
        },
        entities={
            'entityList': [
                {
                    'identifier': {
                        'entityType': 'DocApp::User',
                        'entityId': user_id
                    },
                    'attributes': {
                        'level': {'long': get_user_level(user_id)},
                        'department': {'string': get_user_department(user_id)}
                    },
                    'parents': [
                        {
                            'entityType': 'DocApp::Team',
                            'entityId': get_user_team_id(user_id)
                        }
                    ]
                },
                {
                    'identifier': {
                        'entityType': 'DocApp::Document',
                        'entityId': document_id
                    },
                    'attributes': {
                        'ownerId': {'string': document_attrs['owner_id']},
                        'tenantId': {'string': document_attrs['tenant_id']},
                        'status': {'string': document_attrs['status']}
                    }
                }
            ]
        }
    )

    decision = response['decision']
    determining_policies = response.get('determiningPolicies', [])

    print(f"Authorization: {user_id} {action} {document_id} → {decision}")
    print(f"Determined by policies: {[p['policyId'] for p in determining_policies]}")

    return decision == 'ALLOW'


def batch_authorize_for_ui(user_id: str, document_ids: list[str]) -> dict[str, dict[str, bool]]:
    """
    Check multiple actions across multiple documents for UI rendering.
    Returns: {document_id: {action: allowed}}
    """
    requests = []
    for doc_id in document_ids:
        for action in ['viewDocument', 'editDocument', 'deleteDocument']:
            requests.append({
                'request': {
                    'principal': {'entityType': 'DocApp::User', 'entityId': user_id},
                    'action': {'actionType': 'DocApp::Action', 'actionId': action},
                    'resource': {'entityType': 'DocApp::Document', 'entityId': doc_id}
                },
                'requestId': f"{doc_id}:{action}"
            })

    response = avp.batch_is_authorized(
        policyStoreId=POLICY_STORE_ID,
        requests=[r['request'] for r in requests],
        entities={'entityList': build_entity_list(user_id, document_ids)}
    )

    results = {}
    for i, result in enumerate(response['results']):
        doc_id, action = requests[i]['requestId'].split(':', 1)
        if doc_id not in results:
            results[doc_id] = {}
        results[doc_id][action] = result['decision'] == 'ALLOW'

    return results
```

Latency expectations: single IsAuthorized calls return in 5 to 15ms from the same AWS region. BatchIsAuthorized with 15 to 20 requests returns in 10 to 25ms. For a typical web API handler making one authorization check per request, AVP adds less overhead than a single DynamoDB read.

## SaaS Multi-Tenant Pattern: Shared Store with Tenant Namespacing

![Architecture diagram of a tenant-scoped request checked against one shared Verified Permissions policy store](../../assets/images/blog/amazon-verified-permissions-cedar-tenant.webp)

_Figure: the shared-store pattern in the article. The tenant is a parent of the resource. The policy allows the action only when the principal and the resource belong to that tenant. A store per tenant is the other option, not a second hop. [Open the draw.io source](https://www.joinhorizon.co/examples/architecture-blog-2026/amazon-verified-permissions-cedar/tenant.drawio)._

For a multi-tenant SaaS, Option 2 (shared policy store with tenant ID in resource hierarchy) provides the right balance of operational simplicity and isolation for most use cases.

**Resource naming convention**: embed the tenant ID in the resource path using a parent entity.

```plaintext
// Tenant entity type acts as the parent namespace
// Document::"acme/doc123" is a member of Tenant::"acme"

permit (
  principal in DocApp::Team::"acme-editors",
  action in [DocApp::Action::"viewDocument", DocApp::Action::"editDocument"],
  resource in DocApp::Tenant::"acme"
)
when {
  principal.tenantId == resource.tenantId
};
```

**Policy template for tenant member access** (created once, linked per tenant):

```bash
# Create the template
aws verifiedpermissions create-policy-template \
  --policy-store-id $POLICY_STORE_ID \
  --statement '
    permit (
      principal in ?principal,
      action in [
        DocApp::Action::"viewDocument",
        DocApp::Action::"editDocument"
      ],
      resource in ?resource
    );
  ' \
  --description "Tenant member read-write access"

# Link the template for tenant "acme" — done when a tenant is onboarded
aws verifiedpermissions create-policy \
  --policy-store-id $POLICY_STORE_ID \
  --definition '{
    "templateLinked": {
      "policyTemplateId": "AbCdEfGhIjKlMnOp",
      "principal": {
        "entityType": "DocApp::Team",
        "entityId": "acme-members"
      },
      "resource": {
        "entityType": "DocApp::Tenant",
        "entityId": "acme"
      }
    }
  }'
```

**Cross-tenant access prevention by design**: Cedar policies evaluate exact entity IDs and parent relationships. A user who is a member of `Team::"acme-members"` and a resource that is `in Tenant::"acme"` cannot match a policy for `Tenant::"betacorp"`. The entity hierarchy is explicit in the policy and in the entity context passed to IsAuthorized. There is no "wildcard tenant" vulnerability as long as the resource's tenant entity parent is set correctly in the entities block of your IsAuthorized call.

## Migration Path: Replacing Home-Grown RBAC

A realistic migration takes 2 to 4 weeks for a mature SaaS application. The highest-risk part is the gap between what your database says permissions are and what your code actually enforces.

**Step 1: Inventory existing permissions (week 1)**

Export your roles table and permissions table. Map each permission to a (action, resource_type) pair. Trace through your codebase for all authorization checks by searching for patterns like `user.hasPermission('edit_document')`, `$user->can('editDocument')`, or `if (user.role === 'admin')`. Document each check and the business logic it enforces.

```bash
# Example: extract all permission checks from a Node.js codebase
grep -rn "hasPermission\|can(\|hasRole\|isAuthorized" src/ \
  --include="*.js" --include="*.ts" > permission_inventory.txt
```

**Step 2: Model as Cedar entities and policies (weeks 1 to 2)**

Convert your roles to Cedar policy templates. Each role becomes a template. Each user-role assignment becomes a template-linked policy. Each special override becomes a standalone `permit` or `forbid` policy. Write a test suite for Cedar policy evaluation using the Cedar CLI (`cedar authorize` command for offline policy testing).

```bash
# Test Cedar policies offline before uploading to AVP
cedar authorize \
  --policies policies/ \
  --schema schema.json \
  --principal 'DocApp::User::"alice"' \
  --action 'DocApp::Action::"editDocument"' \
  --resource 'DocApp::Document::"doc123"' \
  --entities entities-test.json
```

**Step 3: Shadow mode deployment (weeks 2 to 3)**

Run both your existing RBAC and AVP IsAuthorized in parallel. Log every case where the decisions diverge. Fix Cedar policies until divergences are below an acceptable threshold (typically under 0.1% of decisions for edge cases you're actively choosing to change behavior on).

```python
def authorize_with_shadow(user_id, action, resource_id):
    old_decision = legacy_rbac.check(user_id, action, resource_id)
    new_decision = avp_authorize(user_id, action, resource_id)

    if old_decision != new_decision:
        cloudwatch.put_metric_data(
            Namespace='Authorization/ShadowMode',
            MetricData=[{
                'MetricName': 'DecisionDivergence',
                'Value': 1,
                'Dimensions': [
                    {'Name': 'Action', 'Value': action},
                    {'Name': 'LegacyDecision', 'Value': str(old_decision)},
                    {'Name': 'AVPDecision', 'Value': str(new_decision)}
                ]
            }]
        )

    return old_decision  # Still enforcing old decision during shadow mode
```

**Step 4: Cut over per route (weeks 3 to 4)**

Switch routes from `return old_decision` to `return new_decision` one group at a time. Start with low-risk read-only endpoints. Save destructive actions (delete, admin operations) for last.

**Step 5: Decommission old tables**

Once all routes use AVP and the divergence rate is zero, the roles/permissions tables become read-only archive. After 30 days of stable operation, drop them. Your database schema simplifies; your authorization logic becomes a managed external service.

## What to Do This Week

1. Inventory permission checks in code; map to (principal, action, resource) triples.
2. Stand up a non-prod policy store; run `cedar authorize` offline fixtures.
3. Deploy shadow mode with divergence metrics, using the [checklist](https://www.joinhorizon.co/examples/architecture-blog-2026/verified-permissions-cedar/avp-shadow-mode-checklist.md).

## What This Post Doesn't Cover

Open Policy Agent / Rego side-by-side, Cedar formal verification tooling beyond CLI authorize, and Cognito identity-pool IAM federation.

Need help with Cedar entity modeling or multi-tenant cutover? [Contact FactualMinds](https://www.joinhorizon.co/contact-us/).

**Related reading:**

- [AWS IAM Best Practices: Least Privilege Access Control](https://www.joinhorizon.co/blog/aws-iam-best-practices-least-privilege-access-control/)
- [AWS Cognito Authentication for SaaS Applications](https://www.joinhorizon.co/blog/aws-cognito-authentication-for-saas-applications/)
- [SaaS Multi-Tenancy on AWS: Silo, Pool, and Bridge Model](https://www.joinhorizon.co/blog/saas-multi-tenancy-on-aws-silo-vs-pool-vs-bridge-model/)
- [Top 20 AWS AI and Modern Services in 2026](https://www.joinhorizon.co/blog/top-20-aws-ai-modern-services-2026/)

## FAQ

### Can I import existing roles from my database into Verified Permissions?
Verified Permissions does not have a bulk import tool that reads a MySQL roles table and generates Cedar policies automatically. The migration is a manual modeling exercise: you export your existing role-permission mappings, translate each into Cedar policy templates (e.g., a "viewer" role becomes a template-linked policy permitting read actions), and use the BatchCreatePolicyTemplate and CreatePolicy APIs to load them into the policy store. For organizations with fewer than 50 distinct role types, this typically takes 1 to 2 weeks. The bigger work is often identifying undocumented permissions that exist in application code but not in any roles table: shadow permissions that only surface through code review.

### How does Verified Permissions handle authorization for multi-step workflows such as approval chains?
Cedar policies can express workflow-aware authorization by including a context attribute representing the current step or status in the authorization request. For example, your application includes `{"workflowStatus": "pending_approval", "requestedByLevel": 3}` in the context map of the IsAuthorized call, and the Cedar policy permits the `approveExpense` action only when `context.workflowStatus == "pending_approval" && principal.level >= 4`. The limitation is that Cedar policies are stateless. They evaluate the context you pass at call time, not a stored workflow state. Your application remains responsible for persisting workflow state (e.g., in DynamoDB) and passing it as context to each authorization call.

### What is the AVP pricing model?
Amazon Verified Permissions pricing is based on authorization requests (IsAuthorized and BatchIsAuthorized API calls) and policy store operations (read/write). As of mid-2026, list pricing is still request-based (IsAuthorized / BatchIsAuthorized) plus policy-store ops; verify the current Pricing page. Illustrative: ~5M auth checks/day is usually tens to low hundreds of dollars/month at published request rates, far below maintaining custom RBAC; re-quote before budgeting.

### Does Verified Permissions support OAuth scopes as action mappings?
Not natively. OAuth scopes (e.g., `documents:read`, `documents:write`) are a coarser-grained authorization mechanism that lives at the API gateway/token layer. They represent what a client application is permitted to do, not what a specific user is permitted to do on a specific resource. The recommended pattern is to use OAuth scopes as a first gate (enforced by API Gateway or a custom authorizer), then use Verified Permissions for fine-grained resource-level authorization behind that gate. You can pass OAuth scope information as context in the IsAuthorized call and write Cedar policies that condition on context.scope values, but there is no built-in OAuth scope-to-Cedar-action mapping.

### How do I audit a specific user's effective permissions across all resources?
Verified Permissions does not have a "what can user X do?" query that returns a complete permission set. The IsAuthorized API is a point-in-time decision for a specific principal/action/resource triple. To audit effective permissions, you have two options: (1) use BatchIsAuthorized with a list of all action/resource combinations you want to check for a given user, which is practical for bounded resource sets like "which documents can user alice view in tenant acme?"; or (2) use the ListPolicies API to enumerate all policies in the store and manually trace which ones apply to a given principal via their group memberships and direct assignments. AWS CloudTrail logs every IsAuthorized call, which provides an event-based audit trail of what authorization decisions were made for a user, even if not a forward-looking permission summary.

---

*Source: https://www.joinhorizon.co/blog/amazon-verified-permissions-cedar/*
