Skip to content

AWS Cognito + Verified Permissions for SaaS Authorization

A deep dive into building SaaS authorization with AWS Cognito and Verified Permissions. Covers Cedar policy language, multi-tenant patterns, JWT token flow, cost analysis, and common mistakes with TypeScript examples.

Abstract

AWS Cognito handles authentication. AWS Verified Permissions (AVP) handles authorization. Together, they form AWS's native SaaS authorization stack. This post walks through how these two services integrate, how Cedar policies enforce tenant isolation, and how to structure multi-tenant authorization for B2B SaaS products. It includes TypeScript integration code, Cedar policy examples, a cost breakdown, and a comparison with the Microsoft Entra ID approach.

Note: This is Part 2 of the External Authorization Systems series. Part 1 covers the broader authorization platform landscape and decision framework.

Cognito's Role in Authorization

Cognito is an authentication service, not an authorization engine. But authentication and authorization are tightly coupled in SaaS systems. Cognito's output -- JWT tokens with claims -- becomes the input for authorization decisions.

JWT Tokens and Claims

After a user authenticates, Cognito issues three tokens:

  • ID Token: Contains user identity claims (email, name, custom attributes). Used for user profile information.
  • Access Token: Contains scopes and authorization-related claims. Used for API authorization.
  • Refresh Token: Used to obtain new ID and access tokens without re-authentication.

For authorization, the access token matters most. It carries the claims that AVP Cedar policies evaluate.

Custom Claims and Tenant Context

Cognito supports custom attributes on user profiles (prefixed with custom:). For SaaS, the critical custom attributes are tenant identifiers and organizational roles:

  • custom:tenantId -- The tenant this user belongs to
  • custom:orgRole -- The user's role within the tenant (admin, editor, viewer)
  • custom:tenantTier -- The tenant's subscription tier (free, pro, enterprise)

These attributes are set during user registration or by an admin API. They are immutable to the user (read-only on the app client) and included in JWT tokens after authentication.

Pre Token Generation Trigger

The Pre Token Generation Lambda trigger is the key integration point between Cognito and AVP. It runs before Cognito issues the access token, allowing you to enrich the token with additional claims that Cedar policies consume.

typescript
// Pre Token Generation V2 trigger -- enrich tokens with tenant context// Cognito invokes this Lambda before issuing the access token
import { PreTokenGenerationV2TriggerEvent } from "aws-lambda";
export const handler = async (  event: PreTokenGenerationV2TriggerEvent) => {  const tenantId = event.request.userAttributes["custom:tenantId"];  const orgRole = event.request.userAttributes["custom:orgRole"];  const tenantTier = event.request.userAttributes["custom:tenantTier"];
  // Add tenant context as claims -- AVP Cedar policies read these  event.response = {    claimsAndScopeOverrideDetails: {      accessTokenGeneration: {        claimsToAddOrOverride: {          tenantId: tenantId,          orgRole: orgRole,          tenantTier: tenantTier,        },        scopesToAdd: orgRole === "admin" ? ["tenant:admin"] : [],      },    },  };
  return event;};

Tip: Keep custom claims minimal. Every additional claim increases token size. Cognito access tokens have a size limit, and bloated tokens add latency to every API call. Only include claims that Cedar policies actively evaluate.

User Pool vs. Identity Pool

Cognito has two components that serve different purposes:

  • User Pool: Manages user authentication (sign-up, sign-in, MFA, password recovery). Issues JWT tokens. This is where tenant context lives.
  • Identity Pool: Maps authenticated users to temporary AWS credentials (IAM roles). Used for direct AWS resource access (S3, DynamoDB).

For SaaS authorization with AVP, you primarily use the User Pool. The Identity Pool is relevant only if your application needs direct AWS resource access from the client side.

AWS Verified Permissions Deep Dive

AVP is a managed authorization service that uses the Cedar policy language. It evaluates authorization requests against Cedar policies and returns allow or deny decisions.

Cedar Policy Language

Cedar is declarative, statically analyzable, and formally verified. Policies express who (principal) can do what (action) to which resource, under what conditions.

A basic Cedar policy:

cedar
// Allow tenant admins to manage all resources within their tenantpermit(  principal,  action in [Action::"ViewReport", Action::"EditReport", Action::"DeleteReport"],  resource)when {  principal.orgRole == "admin" &&  principal.tenantId == resource.tenantId};

Cedar supports two policy types:

  • permit: Grants access when conditions match
  • forbid: Denies access even if a permit policy matches (forbid always wins)

This "deny overrides permit" model makes it straightforward to add safety constraints:

cedar
// Forbid access to archived resources regardless of roleforbid(  principal,  action,  resource)when {  resource.status == "archived"};

Schema Definition

AVP uses a Cedar schema to define your entity types, their attributes, and valid action-entity combinations. The schema is optional but strongly recommended for production. It enables policy validation at creation time, catching typos and structural errors before they reach production.

json
{  "SaasApp": {    "entityTypes": {      "User": {        "shape": {          "type": "Record",          "attributes": {            "tenantId": { "type": "String", "required": true },            "orgRole": { "type": "String", "required": true },            "tenantTier": { "type": "String", "required": true },            "email": { "type": "String", "required": true }          }        },        "memberOfTypes": ["TenantGroup"]      },      "TenantGroup": {        "shape": {          "type": "Record",          "attributes": {            "tenantId": { "type": "String", "required": true }          }        }      },      "Document": {        "shape": {          "type": "Record",          "attributes": {            "tenantId": { "type": "String", "required": true },            "ownerId": { "type": "String", "required": true },            "status": { "type": "String", "required": true },            "sensitivity": { "type": "String", "required": false }          }        }      }    },    "actions": {      "ViewDocument": {        "appliesTo": {          "principalTypes": ["User"],          "resourceTypes": ["Document"]        }      },      "EditDocument": {        "appliesTo": {          "principalTypes": ["User"],          "resourceTypes": ["Document"]        }      },      "DeleteDocument": {        "appliesTo": {          "principalTypes": ["User"],          "resourceTypes": ["Document"]        }      }    }  }}

Policy Store Configuration

A policy store is the container for your Cedar schema, policies, and identity source configuration. The key architectural decision is whether to use one policy store per tenant or a shared policy store for all tenants.

SaaS Multi-Tenant Patterns

Multi-tenancy is the core challenge in SaaS authorization. The question is how to isolate tenants from each other while keeping the authorization infrastructure manageable.

Pool Model vs. Silo Model

Pool model: Single Cognito User Pool with custom:tenantId attribute. Single AVP policy store with tenant-scoped Cedar policies.

  • Simpler to manage and deploy
  • Lower cost (one User Pool, one policy store)
  • Tenant isolation enforced at the Cedar policy level
  • Works for most B2B SaaS products
  • Risk: a policy bug could leak data across tenants

Silo model: Separate User Pool and policy store per tenant.

  • Stronger isolation boundary (infrastructure-level separation)
  • Required for compliance-heavy industries (healthcare, finance, government)
  • Higher operational overhead and cost
  • Each tenant can have customized policies
  • More complex deployment and management

Tip: Start with the pool model. It handles most cases well. Move to silo only when compliance or contractual requirements demand infrastructure-level tenant isolation.

Cedar Policies for Multi-Tenancy

In the pool model, tenant isolation is entirely policy-driven. Every resource access policy must include a tenant check:

cedar
// Base policy: tenant isolation for document viewing// Every resource policy must verify tenantId matchpermit(  principal,  action == Action::"ViewDocument",  resource is SaasApp::Document)when {  principal.tenantId == resource.tenantId &&  principal.orgRole in ["admin", "editor", "viewer"]};
// Editors can modify documents within their tenantpermit(  principal,  action == Action::"EditDocument",  resource is SaasApp::Document)when {  principal.tenantId == resource.tenantId &&  principal.orgRole in ["admin", "editor"]};
// Only admins can delete documentspermit(  principal,  action == Action::"DeleteDocument",  resource is SaasApp::Document)when {  principal.tenantId == resource.tenantId &&  principal.orgRole == "admin"};

For tier-based feature gating, Cedar policies can check the tenant tier:

cedar
// Only enterprise tenants can access advanced analyticspermit(  principal,  action == Action::"ViewAdvancedAnalytics",  resource)when {  principal.tenantId == resource.tenantId &&  principal.tenantTier == "enterprise"};

Cross-Tenant Access

Some SaaS products need controlled cross-tenant access -- for example, a consultant accessing multiple client tenants. This requires explicit cross-tenant policies:

cedar
// Allow cross-tenant access via explicit sharing// This policy only applies to users with a sharedTenants attributepermit(  principal,  action == Action::"ViewDocument",  resource is SaasApp::Document)when {  resource.tenantId in principal.sharedTenants};

Warning: Cross-tenant policies are powerful and risky. They bypass the standard tenant isolation boundary. Review them carefully and test with dedicated integration tests.

Integration Architecture

The following diagram shows how Cognito tokens flow through the system to reach AVP for authorization decisions.

The flow works in two tiers:

  1. API Gateway + Cognito Authorizer (coarse-grained): Validates the JWT token, checks that the user is authenticated and the token is not expired. Rejects unauthenticated requests before they reach Lambda.
  2. Lambda + AVP (fine-grained): Extracts tenant context from the validated token, calls AVP with the principal, action, and resource. AVP evaluates Cedar policies and returns allow or deny.

JWT Extraction and Tenant Context

typescript
// Extract tenant context from a validated Cognito JWT// API Gateway Cognito Authorizer has already validated the token
import { APIGatewayProxyEventV2WithJWTAuthorizer } from "aws-lambda";
interface TenantContext {  userId: string;  tenantId: string;  orgRole: string;  tenantTier: string;  email: string;}
function extractTenantContext(  event: APIGatewayProxyEventV2WithJWTAuthorizer): TenantContext {  const claims = event.requestContext.authorizer.jwt.claims;
  return {    userId: claims.sub as string,    tenantId: claims.tenantId as string,    orgRole: claims.orgRole as string,    tenantTier: claims.tenantTier as string,    email: claims.email as string,  };}

IsAuthorized and IsAuthorizedWithToken

AVP provides two authorization APIs:

  • IsAuthorized: You construct the principal entity manually. Useful when the caller is a backend service, not a user with a JWT.
  • IsAuthorizedWithToken: You pass the raw Cognito JWT token. AVP validates the token and extracts the principal automatically. This is simpler and more secure for user-facing requests.
typescript
import {  VerifiedPermissionsClient,  IsAuthorizedCommand,  IsAuthorizedWithTokenCommand,  BatchIsAuthorizedCommand,} from "@aws-sdk/client-verifiedpermissions";
const avpClient = new VerifiedPermissionsClient({ region: "eu-central-1" });const POLICY_STORE_ID = "ps-your-store-id";
// Option 1: IsAuthorizedWithToken -- pass raw JWT// AVP validates the token and extracts principal from claimsasync function checkWithToken(  accessToken: string,  action: string,  resourceType: string,  resourceId: string,  resourceAttrs: Record<string, { string: string }>): Promise<boolean> {  const command = new IsAuthorizedWithTokenCommand({    policyStoreId: POLICY_STORE_ID,    accessToken: accessToken,    action: {      actionType: "SaasApp::Action",      actionId: action,    },    resource: {      entityType: `SaasApp::${resourceType}`,      entityId: resourceId,      entityAttributes: resourceAttrs,    },  });
  const response = await avpClient.send(command);  return response.decision === "ALLOW";}
// Option 2: IsAuthorized -- construct principal manually// Useful for service-to-service calls without a user JWTasync function checkWithPrincipal(  userId: string,  userAttrs: Record<string, { string: string }>,  action: string,  resourceType: string,  resourceId: string,  resourceAttrs: Record<string, { string: string }>): Promise<boolean> {  const command = new IsAuthorizedCommand({    policyStoreId: POLICY_STORE_ID,    principal: {      entityType: "SaasApp::User",      entityId: userId,      entityAttributes: userAttrs,    },    action: {      actionType: "SaasApp::Action",      actionId: action,    },    resource: {      entityType: `SaasApp::${resourceType}`,      entityId: resourceId,      entityAttributes: resourceAttrs,    },  });
  const response = await avpClient.send(command);  return response.decision === "ALLOW";}

BatchIsAuthorized for UI Rendering

When rendering a UI with multiple resources (a document list, a dashboard with permission-gated features), making individual authorization calls is expensive. BatchIsAuthorizedCommand sends up to 30 checks in a single API call.

The constraint: either the principal or the resource must be identical across all requests in the batch. For UI rendering, you typically fix the principal (the current user) and vary the action/resource.

typescript
// Batch authorization for UI rendering// Check multiple permissions for a single user in one API callasync function checkBatchPermissions(  userId: string,  userAttrs: Record<string, { string: string }>,  checks: Array<{    action: string;    resourceType: string;    resourceId: string;    resourceAttrs: Record<string, { string: string }>;  }>): Promise<boolean[]> {  const command = new BatchIsAuthorizedCommand({    policyStoreId: POLICY_STORE_ID,    requests: checks.map((check) => ({      principal: {        entityType: "SaasApp::User",        entityId: userId,        entityAttributes: userAttrs,      },      action: {        actionType: "SaasApp::Action",        actionId: check.action,      },      resource: {        entityType: `SaasApp::${check.resourceType}`,        entityId: check.resourceId,        entityAttributes: check.resourceAttrs,      },    })),  });
  const response = await avpClient.send(command);  return response.results!.map((r) => r.decision === "ALLOW");}
// Usage: check what the user can do with a list of documentsconst permissions = await checkBatchPermissions(  "user-123",  { tenantId: { string: "tenant-abc" }, orgRole: { string: "editor" } },  [    {      action: "ViewDocument",      resourceType: "Document",      resourceId: "doc-1",      resourceAttrs: { tenantId: { string: "tenant-abc" } },    },    {      action: "EditDocument",      resourceType: "Document",      resourceId: "doc-1",      resourceAttrs: { tenantId: { string: "tenant-abc" } },    },    {      action: "DeleteDocument",      resourceType: "Document",      resourceId: "doc-1",      resourceAttrs: { tenantId: { string: "tenant-abc" } },    },  ]);// permissions: [true, true, false] -- can view and edit, cannot delete

API Middleware Pattern

A reusable middleware that connects Cognito authentication with AVP authorization:

typescript
// Middleware connecting Cognito JWT with AVP authorization// Wraps Lambda handlers with automatic permission checking
import { APIGatewayProxyEventV2WithJWTAuthorizer } from "aws-lambda";
interface AuthorizationConfig {  action: string;  resourceType: string;  getResourceId: (event: APIGatewayProxyEventV2WithJWTAuthorizer) => string;  getResourceAttrs: (    event: APIGatewayProxyEventV2WithJWTAuthorizer  ) => Record<string, { string: string }>;}
function withAuthorization(config: AuthorizationConfig) {  return function (    handler: (      event: APIGatewayProxyEventV2WithJWTAuthorizer    ) => Promise<unknown>  ) {    return async (event: APIGatewayProxyEventV2WithJWTAuthorizer) => {      const context = extractTenantContext(event);
      const allowed = await checkWithPrincipal(        context.userId,        {          tenantId: { string: context.tenantId },          orgRole: { string: context.orgRole },          tenantTier: { string: context.tenantTier },        },        config.action,        config.resourceType,        config.getResourceId(event),        config.getResourceAttrs(event)      );
      if (!allowed) {        return {          statusCode: 403,          body: JSON.stringify({ error: "Access denied" }),        };      }
      return handler(event);    };  };}
// Usage in a Lambda handlerexport const getDocument = withAuthorization({  action: "ViewDocument",  resourceType: "Document",  getResourceId: (event) => event.pathParameters?.documentId ?? "",  getResourceAttrs: (event) => {    // In practice, fetch resource attributes from your database    // before the authorization check    return { tenantId: { string: "tenant-abc" }, status: { string: "active" } };  },})(async (event) => {  // Handler runs only if authorization passed  const documentId = event.pathParameters?.documentId;  // ... fetch and return document  return { statusCode: 200, body: JSON.stringify({ id: documentId }) };});

Entra ID Alternative

For teams in the Microsoft ecosystem, the equivalent pattern uses Microsoft Entra External ID for authentication and a separate authorization layer for fine-grained decisions.

Key Differences

AspectAWS (Cognito + AVP)Microsoft (Entra + custom authz)
AuthenticationCognito User PoolEntra External ID
Authorization engineAVP with Cedar (managed)No native equivalent -- use Cerbos, OPA, or custom
Policy languageCedar (formally verified)Depends on chosen engine
Tenant isolationCedar policies or separate policy storesApp roles + groups + external PDP
Token enrichmentPre Token Generation Lambda triggerClaims mapping in app registration
Pricing modelPer-MAU (Cognito) + per-request (AVP)Per-MAU (Entra) + external PDP cost
Multi-tenant supportCustom attributes + Cedar policiesMulti-tenant app registration

The critical gap in the Microsoft stack is the absence of a managed fine-grained authorization service. Entra handles authentication and coarse-grained authorization (app roles, group memberships, Conditional Access), but for resource-level decisions like "can user X edit document Y in tenant Z," you need an external PDP.

In practice, Microsoft-stack teams pair Entra with Cerbos or OPA for fine-grained decisions. The Entra token provides identity context (user ID, roles, groups, tenant ID), and the external PDP evaluates policies using that context plus resource attributes.

typescript
// Entra ID token claims fed into Cerbos for fine-grained authorization// Entra provides identity; Cerbos makes the authorization decision
import { GRPC as Cerbos } from "@cerbos/grpc";
const cerbos = new Cerbos("localhost:3593");
async function checkAccess(  entraToken: { oid: string; roles: string[]; tid: string; groups: string[] },  resource: { type: string; id: string; ownerId: string; tenantId: string }) {  const decision = await cerbos.checkResource({    principal: {      id: entraToken.oid,      roles: entraToken.roles,      attr: {        tenantId: entraToken.tid,        groups: entraToken.groups,      },    },    resource: {      kind: resource.type,      id: resource.id,      attr: {        owner: resource.ownerId,        tenantId: resource.tenantId,      },    },    actions: ["read", "update", "delete"],  });
  return decision.isAllowed("update");}

AWS has a clear advantage here: Cognito + AVP is a fully integrated, managed authorization stack. In the Microsoft ecosystem, you assemble it from multiple parts.

Cost Analysis

AVP Pricing

Amazon Verified Permissions bills single-authorization APIs, batch-authorization APIs, and policy management APIs on different rate cards. There is no single “flat” rate for every API. See Amazon Verified Permissions Pricing.

Single authorization (IsAuthorized, IsAuthorizedWithToken): each API call is one metered authorization request at 0.000005perrequest(0.000005 per request** (**5 per million).

Batch authorization (BatchIsAuthorized, BatchIsAuthorizedWithToken): each batch API call is metered as one request, regardless of how many checks you include in that call (up to the service limit). Batch calls use tiered per-call pricing (for example, the first 40 million batch calls per month at $0.00015 per batch call on the published US rate card). That is not the same as paying the single-auth rate for every inner check.

The table below applies only when every fine-grained decision maps to one IsAuthorized or IsAuthorizedWithToken call.

Monthly single-auth API callsMonthly AVP cost (single APIs)
1 million$5
10 million$50
100 million$500
1 billion$5,000

Note: If you rely heavily on batch APIs, model cost using the batch tiers on the pricing page. Policy management calls (create, update, list policies, etc.) are billed separately.

Cognito Pricing

Cognito pricing depends on the tier:

TierFree MAUsPrice per MAU (above free)Key Features
Lite10,000$0.0055Basic auth, custom attributes
Essentials10,000$0.015Passwordless, managed login
Plus0$0.02Threat protection, risk-based auth

Note: Accounts with active Cognito user pools before November 2024 are eligible for a higher free tier of 50,000 MAUs.

Total SaaS Auth Cost Example

For a B2B SaaS product with 5,000 MAUs making an average of 200 API calls per day:

  • Cognito (Lite): Free (under 10,000 MAU threshold)
  • AVP: 5,000 users x 200 calls x 30 days = 30 million single authorization API calls/month = $150/month (assuming each call is IsAuthorized or IsAuthorizedWithToken at the single-auth rate)
  • Total: ~$150/month for authentication + fine-grained authorization

If you satisfy the same checks with batch APIs instead, AWS meters batch API calls at the batch rate card, not one billed unit per inner authorization check.

For comparison, a self-hosted solution (SpiceDB on Kubernetes + PostgreSQL) would cost $500-2,000/month in infrastructure alone, plus engineering time for operations.

Tip: Not every API call needs an AVP check. Use the two-tier pattern: API Gateway handles coarse-grained checks (is the user authenticated? does the token have the right scope?). Only route fine-grained decisions to AVP. This can reduce AVP request volume by 60-80%.

Common Mistakes

Over-Scoping Policy Stores

Creating a separate policy store per microservice leads to policy fragmentation. Authorization decisions that span multiple services become impossible within a single policy store. In most cases, a single policy store per environment (dev, staging, production) is sufficient. Use Cedar namespacing (entity type prefixes) to organize policies logically.

Missing Tenant Isolation in Policies

The most dangerous mistake in multi-tenant SaaS: writing a permit policy without a tenant check.

cedar
// DANGEROUS: no tenant isolationpermit(  principal,  action == Action::"ViewDocument",  resource is SaasApp::Document)when {  principal.orgRole in ["admin", "editor", "viewer"]};

This policy allows any authenticated user to view any document, regardless of tenant. The fix is straightforward but easy to forget:

cedar
// CORRECT: tenant isolation enforcedpermit(  principal,  action == Action::"ViewDocument",  resource is SaasApp::Document)when {  principal.tenantId == resource.tenantId &&  principal.orgRole in ["admin", "editor", "viewer"]};

Warning: Write a Cedar policy test that explicitly verifies cross-tenant access is denied. Run it in CI/CD. This is your safety net against accidental tenant data leakage.

Token Bloat

Adding too many claims to Cognito tokens through the Pre Token Generation trigger increases token size. Large tokens add latency to every API call and can exceed size limits. Keep claims to what Cedar policies actually evaluate. If you need additional context for authorization, pass it as resource attributes in the AVP request rather than token claims.

Not Using IsAuthorizedWithToken

Manually extracting JWT claims and constructing principal entities is error-prone. IsAuthorizedWithToken lets AVP handle token validation and claim extraction. It is more secure (AVP verifies the token signature) and less code to maintain.

Ignoring Batch Authorization for UI

Making individual IsAuthorized calls in a loop for UI rendering creates latency problems and increases single-auth billing units. When you need many checks for the same principal, BatchIsAuthorizedCommand packs up to 30 checks into one batch API call, which is metered as one batch request (with its own tiered price per batch call). Compare single-auth versus batch totals on the pricing page for your traffic shape.

Skipping Schema Validation

Deploying Cedar policies without a schema means typos in attribute names silently fail. A policy checking principal.tenantid (lowercase 'd') instead of principal.tenantId will never match, and no error is reported. Enable schema validation to catch these issues at policy creation time.

Conclusion

Cognito + AVP provides a managed, integrated authorization stack for SaaS products on AWS. The combination handles authentication, tenant context enrichment, and fine-grained policy evaluation without self-hosted infrastructure.

The key takeaways:

  1. Use the Pre Token Generation trigger to enrich access tokens with tenant context that Cedar policies consume.
  2. Start with the pool model (single User Pool, single policy store) and move to silo only when compliance demands it.
  3. Every Cedar policy must include a tenant check in multi-tenant applications. Test cross-tenant isolation in CI/CD.
  4. Use IsAuthorizedWithToken for user-facing requests. Use IsAuthorized for service-to-service calls.
  5. Use the two-tier pattern (API Gateway for coarse checks, AVP for fine-grained) to control costs and latency.
  6. Use BatchIsAuthorized for UI rendering to reduce API calls and latency.

The next post in this series covers SpiceDB vs Auth0 FGA for relationship-based access control, where the authorization model is fundamentally different from the attribute-based approach covered here.

References

External Authorization Systems

A comprehensive guide to external authorization platforms for distributed systems. Covers platform selection, policy language comparison, cloud-native authorization with AWS, and relationship-based access control with SpiceDB and Auth0 FGA.

Progress2/4 posts completed

Related Posts