AWS Verified Permissions · 2026-10-23 · AVP advanced authorization arc
AWS Verified Permissions for MCP Servers: Cedar Schema Design, ABAC Policy Patterns, Policy Templates, and the IsAuthorized API
AWS Verified Permissions (AVP) is a managed, deny-by-default authorization service that evaluates Cedar policies against every incoming request in under 10 milliseconds — it gives MCP servers the per-tool, per-tenant access control that IAM alone cannot express. Four facts about AVP routinely cause production incidents: (1) the Cedar namespace in the schema must be a non-empty string — an empty namespace causes all CreatePolicy calls to fail with schema validation errors, and the policy store becomes unusable until you call PutSchema again with a valid namespace; (2) AVP returns DENY when no policy matches — deny is the default, not an error condition — so deploying a new tool without writing a permit policy silently blocks every caller until you notice; (3) forbid beats permit absolutely — there is no priority score, weight, or override that makes a permit win against a forbid, and this is the right model for suspension and emergency revocation; (4) IsAuthorizedWithToken requires a Cognito identity source configured on the policy store before it will accept JWTs — it is not a drop-in replacement for IsAuthorized without the identity source setup step. This guide synthesises four AVP topics into four structural patterns for teams building multi-tenant MCP servers. Pattern 1 covers Cedar schema design and policy store provisioning — namespace requirements, entity types for tiered users and tools, action groups for batch authorization, and the clientToken idempotency field that makes CDK deploys safe — synthesised from AWS Verified Permissions for MCP Servers — Cedar Authorization, Policy Stores. Pattern 2 covers Cedar policy language — permit and forbid syntax, when/unless condition clauses, ABAC patterns for tier-gating and tenant isolation, absent-attribute type errors and the has guard, action groups and transitive group membership, and the open-source Cedar CLI for local policy testing before uploading to AVP — synthesised from Cedar Policy Language for MCP Server Authorization — Permit, Forbid, ABAC. Pattern 3 covers policy templates for multi-tenant MCP servers — static versus template-linked policy trade-offs, creating a template with ?principal and ?resource placeholders, bulk-provisioning linked policies for new tenant signups, atomic template updates that cascade to all tenants, and the delete-linked-first ordering constraint that trips teams on template removal — synthesised from AWS Verified Permissions Policy Templates for Multi-Tenant MCP Servers. Pattern 4 covers the IsAuthorized API family — the entities block that is required for Cedar ABAC conditions to evaluate, IsAuthorizedWithToken with Cognito JWT federation and custom-attribute prefix stripping, BatchIsAuthorized for session-start tool filtering, performance characteristics and LRU caching, and error classification distinguishing no-policy-match from evaluation failure — synthesised from AVP IsAuthorized and IsAuthorizedWithToken API for MCP Servers.
TL;DR
- Cedar schema design — namespace, entity types, action groups, policy store creation: Create one policy store per environment via the AWS SDK or CDK with
validationSettings.mode: 'STRICT'to catch Cedar typos at policy-write time, and pass aclientTokento make the create call idempotent across CDK re-deploys. The Cedar schema must define at least three entity types for an MCP authorization model: a principal type (e.g.,MCP::Userwithplan,tenantId, andisVerifiedattributes), a resource type (MCP::Toolwithcategoryandtierattributes), and optionally a group type (MCP::Group) withmemberOfTypes: ["Group"]on the User type to enable Cedar's transitiveinoperator. The namespace string (the"MCP"key at the root of the schema JSON) must be non-empty — an empty string causes all subsequentCreatePolicycalls to fail. Define action groups (e.g.,CallReadToolandCallWriteToolboth members ofCallTool) in the schema so Cedar'saction in [...]syntax works at policy evaluation time. Store thepolicyStoreIdin SSM Parameter Store — it is not a secret but the server needs it at startup. - Cedar policy patterns — permit/forbid, ABAC conditions, absent-attribute guard, action groups, Cedar CLI: Cedar syntax is
effect(principal [constraint], action [constraint], resource [constraint]) [when/unless { condition }];— all three head clauses are required (use the bare keyword for unscoped wildcard). The critical semantics: deny is the default when no policy matches;forbidoverrides allpermitpolicies unconditionally. For multi-tenant MCP servers, four ABAC patterns cover almost all access scenarios: (1) tier-gated tool access viaprincipal.planandresource.tierattribute comparison; (2) tenant isolation viaprincipal.tenantId == resource.tenantIdequality; (3) verified-author admin access viaprincipal.isVerified && resource.category == "admin"; (4) emergency suspension via aforbid(principal == MCP::User::"id", action, resource)that immediately overrides all permits. The absent-attribute trap: if a Cedar condition readsprincipal.planbut the principal entity in theentitiesblock has noplanattribute, AVP skips the policy (counts as non-match, not false) — guard every attribute read withhas:principal has plan && principal.plan == "free". Test Cedar policies locally before uploading:cedar authorize --policies policies.cedar --entities entities.json --principal 'MCP::User::"u1"' --action 'MCP::Action::"CallTool"' --resource 'MCP::Tool::"list_endpoints"'. - Policy templates — static vs template-linked, tenant provisioning, cascading updates, delete ordering: A policy template is a Cedar policy body with
?principaland?resourceas unbound placeholders in the head (not inwhenconditions). Create a template withCreatePolicyTemplate; for each (user, tool-set) grant create a linked policy withCreatePolicy({ policyType: 'TEMPLATE_LINKED', policyTemplateId, principal, resource })— the policyId returned must be stored in your database for later revocation. Updating the template withUpdatePolicyTemplateimmediately cascades to all linked policies — a single API call changes the permission rule for every tenant simultaneously. For new tenant signups, usePromise.allSettledto batch-create linked policies with per-concurrency control since AVP rate-limitsCreatePolicy. Audit all grants under a template withListPolicies({ filter: { policyTemplateId } }). Delete ordering: delete all linked policies first — AVP rejectsDeletePolicyTemplateif any linked policies exist. AliveMCP uses two templates: one for Author-tier read tools (all Author users granted read access to all Author-tier tools) and one for Team-tier write tools (per-team grants to write tools with a team resource constraint). - IsAuthorized API — entities block, IsAuthorizedWithToken, BatchIsAuthorized, performance, error handling: Always pass the
entitiesblock in everyIsAuthorizedcall — Cedar conditions that readprincipal.planorresource.tierreturn a type error and the policy is skipped (not evaluated as DENY) when the entity is absent from the entities list. ThedeterminingPoliciesarray is only populated onALLOWdecisions — onDENYwith no matching policy, the array is empty, which is different from DENY with a matching forbid policy (where the forbid policy appears in the array). For Cognito-authenticated users, create a Cognito identity source on the policy store first (CreateIdentitySourceCommand), then callIsAuthorizedWithTokenwith the ID token — AVP parses the JWT claims and mapscustom:plan→principal.plan(stripping thecustom:prefix). UseBatchIsAuthorized(up to 30 checks per call) at MCP session start to pre-filter the tool list — return only tools the caller can access, saving the agent from discovering denied tools mid-session. Performance:IsAuthorizedis 3–10ms p50; implement a per-session LRU cache with 30-second TTL forALLOWdecisions only (never cacheDENY— suspension must take effect immediately). Always checkresult.errors[]alongsideresult.decision— non-fatal attribute type errors appear inerrorswhile the decision is still returned.
Pattern 1 — Cedar Schema Design and Policy Store Provisioning
Why MCP servers need server-side authorization
An MCP server that skips per-tool authorization relies on its calling client — Claude Desktop, a custom agent, a third-party integration — to enforce access rules. But the calling client is not a trust boundary you control. Four scenarios require server-side enforcement:
- Multi-tenant isolation: Tool A queries tenant A's private database. Tool B queries tenant B's private database. The agent orchestrator routing the call doesn't know which tools are private — only the server does.
- Tiered access plans: Free-plan users get read-only tools. Paid users get write tools. The billing system knows the plan tier; the agent doesn't.
- Audit requirements: Enterprise customers require a policy-as-code record of which principals can call which tools, with an immutable log. IAM policies can't express tool-level granularity for application-layer resources.
- Runtime revocation: A compromised API key must be blocked on the next tool call — within seconds — without redeploying the MCP server. IAM key rotation takes minutes; AVP policy updates take effect immediately on the next
IsAuthorizedcall.
AVP handles all four. Cedar policies are evaluated per request at under 10ms. Policy changes propagate immediately. CloudTrail logs every authorization decision with the policyId(s) that determined the outcome.
Creating a policy store
Create one policy store per environment (dev, staging, prod). Use validationSettings.mode: 'STRICT' to have AVP validate Cedar syntax against the schema whenever you create or update a policy — this catches typos and schema mismatches at write time rather than at authorization time when a real request is denied. Pass a clientToken to make the call idempotent across CDK deploys: if the stack re-runs and the policy store already exists, AVP returns the existing store ID rather than creating a duplicate.
import {
VerifiedPermissionsClient,
CreatePolicyStoreCommand,
PutSchemaCommand,
} from '@aws-sdk/client-verifiedpermissions';
const avp = new VerifiedPermissionsClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
const store = await avp.send(new CreatePolicyStoreCommand({
validationSettings: {
mode: 'STRICT', // validates Cedar policies against schema on write
},
clientToken: 'mcp-server-prod-v1', // idempotent across CDK re-deploys
description: 'AliveMCP production tool authorization',
}));
const policyStoreId = store.policyStoreId!;
// Store in SSM: /alivemcp/prod/avp-policy-store-id
// Not a secret, but server needs it at startup for every IsAuthorized call
Cedar schema design for tiered users and tools
The Cedar schema defines the entity types, attributes, and actions that your policies can reference. For a multi-tenant MCP server with tiered access plans, you need at minimum: a principal type, a resource type, a group type for plan-tier groups, and the tool call actions.
const mcpSchema = {
"MCP": { // namespace — must be non-empty string; empty namespace = all CreatePolicy calls fail
"entityTypes": {
"User": {
"shape": {
"type": "Record",
"attributes": {
"plan": { "type": "String" }, // "free" | "author" | "team" | "enterprise"
"tenantId": { "type": "String" },
"isVerified": { "type": "Boolean" }
}
},
"memberOfTypes": ["Group"] // enables principal in MCP::Group::"team-tier" syntax
},
"Group": {
"shape": { "type": "Record", "attributes": {} }
},
"Tool": {
"shape": {
"type": "Record",
"attributes": {
"category": { "type": "String" }, // "read" | "write" | "admin"
"tier": { "type": "String" }, // "free" | "paid"
"tenantId": { "type": "String" } // owner tenant for tenant-scoped tools
}
}
}
},
"actions": {
"CallReadTool": { "memberOf": [{ "id": "CallTool", "type": "Action" }],
"appliesTo": { "principalTypes": ["User"], "resourceTypes": ["Tool"] } },
"CallWriteTool": { "memberOf": [{ "id": "CallTool", "type": "Action" }],
"appliesTo": { "principalTypes": ["User"], "resourceTypes": ["Tool"] } },
"CallTool": {
"appliesTo": {
"principalTypes": ["User"],
"resourceTypes": ["Tool"],
"context": {
"type": "Record",
"attributes": {
"requestedAt": { "type": "String" },
"clientIp": { "type": "String" }
}
}
}
}
}
}
};
await avp.send(new PutSchemaCommand({
policyStoreId,
definition: { cedarJson: JSON.stringify(mcpSchema) },
}));
Three schema decisions that affect your entire policy model:
memberOfTypes: ["Group"]on User — this enables Cedar's transitiveinoperator. A user in groupauthor-tierwhich is in grouppaid-tierswill matchprincipal in MCP::Group::"paid-tiers"without you writing a condition on every sub-group explicitly.- Action groups — defining
CallReadToolandCallWriteToolas members ofCallToollets you writeaction in [MCP::Action::"CallReadTool", MCP::Action::"CallWriteTool"]and have Cedar resolve the membership transitively. UseBatchIsAuthorizedwith the specific action IDs to pre-filter tools at session start, then use the parent action in broader permit policies. - Tool.tenantId attribute — adding
tenantIdto both User and Tool entities is the foundation of tenant isolation. A single Cedar condition —principal.tenantId == resource.tenantId— enforces that users can only call tools owned by their tenant, regardless of how the tool list is constructed.
Pattern 2 — Cedar Policy Language: ABAC, Forbid, Action Groups, and the Cedar CLI
Cedar syntax fundamentals
A Cedar policy is a single statement with this grammar:
effect (
principal [is EntityType] [== EntityRef] [in EntityRef],
action [== ActionRef] [in [ActionRef, ...]],
resource [is EntityType] [== EntityRef] [in EntityRef]
)
[when { booleanExpr }]
[unless { booleanExpr }]
;
All three head clauses are required. Use the bare keyword — principal, action, resource — for an unscoped wildcard that matches any entity of that role. The two effects: permit allows the action if no forbid policy matches. forbid denies the action unconditionally — no permit policy can override a forbid match, regardless of policy creation order or description. This is not a "last-write-wins" model — it is an absolute override.
ABAC patterns for tiered MCP tool access
Attribute-based access control lets a small number of policies cover a large number of users and resources without creating one policy per (user, tool) pair. These four patterns handle the majority of MCP authorization scenarios:
// Pattern 1: Tier-gated tool access
// Free users see free tools; paid users see free + paid tools
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
(principal.plan == "free" && resource.tier == "free") ||
(principal.plan in ["author", "team", "enterprise"])
};
// Pattern 2: Tenant isolation — a user can only call tools in their own tenant
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
principal has tenantId &&
resource has tenantId &&
principal.tenantId == resource.tenantId
};
// Pattern 3: Verified-author admin access
// Only verified authors on enterprise plan can call admin tools
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
principal has isVerified &&
principal.isVerified &&
principal.plan == "enterprise" &&
resource.category == "admin"
};
// Pattern 4: Emergency suspension — forbid beats all permits immediately
forbid (
principal == MCP::User::"compromised-key-xyz",
action,
resource
);
The absent-attribute trap and the has guard
This is the Cedar behavior that causes the most subtle production bugs. If a Cedar when condition reads an attribute that is absent from the entity passed in the entities block, AVP does not treat the condition as false — it treats it as a type error and skips the entire policy. The policy counts as non-matching, not as denying the request. The result depends on what other policies exist:
- If another permit policy matches, the request is
ALLOW— the broken policy silently has no effect. - If no other permit policy matches, the request is
DENY— the caller can't call the tool, but the error is invisible (no error in theerrors[]array, just a silent DENY).
Guard every attribute read with has before accessing it:
// WRONG: skips the policy if principal has no "plan" attribute
permit (principal is MCP::User, action == MCP::Action::"CallTool", resource is MCP::Tool)
when { principal.plan == "free" && resource.tier == "free" };
// CORRECT: evaluates to false (not type error) if plan is absent
permit (principal is MCP::User, action == MCP::Action::"CallTool", resource is MCP::Tool)
when {
principal has plan &&
resource has tier &&
principal.plan == "free" &&
resource.tier == "free"
};
Action groups for fine-grained BatchIsAuthorized
Action groups in the Cedar schema let you express hierarchical action relationships. A policy that permits CallTool implicitly permits CallReadTool and CallWriteTool (any action that is a member of CallTool). A policy that explicitly permits only CallReadTool does not permit CallWriteTool, even though both are members of CallTool. This lets you write write-restricted plans:
// Author-tier read-only policy using action group membership
permit (
principal is MCP::User,
action in [MCP::Action::"CallReadTool"], // not CallWriteTool
resource is MCP::Tool
)
when { principal.plan == "author" };
// Team-tier full access (read + write, not admin)
permit (
principal is MCP::User,
action in [MCP::Action::"CallReadTool", MCP::Action::"CallWriteTool"],
resource is MCP::Tool
)
when {
principal.plan in ["team", "enterprise"] &&
resource.category != "admin"
};
Testing Cedar policies locally before uploading to AVP
The open-source Cedar CLI lets you evaluate policies against a local entities JSON file before creating them in AVP. This is faster than iterating against the live service and catches absent-attribute errors during development:
# Install Cedar CLI
cargo install cedar-policy-cli
# Test a single permit decision
cedar authorize \
--policies policies.cedar \
--entities entities.json \
--principal 'MCP::User::"user-alice"' \
--action 'MCP::Action::"CallTool"' \
--resource 'MCP::Tool::"list_private_endpoints"' \
--context '{"requestedAt": "2026-10-23T00:00:00Z"}'
# Output: ALLOW (or DENY with reason)
# Validate policies against your schema (catches type errors)
cedar validate \
--schema schema.json \
--policies policies.cedar
Run Cedar CLI in your CI pipeline against a fixture entities file before deploying policy changes to the AVP policy store. The CLI is deterministic and has no AWS API dependency — it runs entirely locally against the open-source Cedar evaluator, the same engine AVP uses in production.
Pattern 3 — Policy Templates for Multi-Tenant MCP Servers
Why static policies don't scale for multi-tenancy
A static policy has its principal and resource hard-coded in the policy body. Granting 1,000 tenants access to a tier's tool set requires 1,000 individual static policies — and if you need to change the access rule (say, adding a new condition to restrict write tools to verified authors only), you must update all 1,000 policies individually.
Policy templates solve this with a single shared policy body parameterized over ?principal and ?resource placeholders:
| Property | Static policy | Template-linked policy |
|---|---|---|
| Principal/resource binding | Hard-coded in policy body at creation | Supplied at link time; template body uses ?principal, ?resource |
| Updating the rule | Edit each policy individually | Edit template once — all linked policies update atomically |
| Scale for 1,000 tenants | 1,000 policies × N conditions to update | 1 template edit cascades to all 1,000 linked policies |
| Audit — who has access | No grouping mechanism | ListPolicies(filter: { policyTemplateId }) returns all grants |
| Revocation | Delete or update the individual policy | Delete the linked policy for that (principal, resource) pair |
| Use when | Bespoke one-off grants; complex rules unique to one caller | Per-tenant provisioning; billing-event-triggered access; per-user resource grants |
Creating a policy template
The template body is Cedar syntax with ?principal and ?resource as literal placeholder tokens in the head. These placeholders cannot appear in the when clause — they can only appear in the principal and resource scopes. Conditions in when use entity attributes that AVP resolves at evaluation time from the bound entity's actual attributes:
import {
CreatePolicyTemplateCommand,
CreatePolicyCommand,
UpdatePolicyTemplateCommand,
ListPoliciesCommand,
} from '@aws-sdk/client-verifiedpermissions';
// Create the template — ?principal and ?resource are the placeholders
const template = await avp.send(new CreatePolicyTemplateCommand({
policyStoreId,
description: 'Author-tier read access to paid tools',
statement: `
permit (
principal == ?principal,
action in [MCP::Action::"CallReadTool"],
resource == ?resource
)
when {
?principal has plan &&
?principal.plan in ["author", "team", "enterprise"]
};
`,
}));
const authorReadTemplateId = template.policyTemplateId!;
Creating template-linked policies for tenant provisioning
When a new tenant signs up for the Author plan, create a linked policy binding the user entity to the tool-group resource. Store the returned policyId — you need it for revocation when the subscription expires or is cancelled:
async function provisionAuthorAccess(
userId: string,
toolGroupResource: string,
): Promise<string> {
const policy = await avp.send(new CreatePolicyCommand({
policyStoreId,
definition: {
templateLinked: {
policyTemplateId: authorReadTemplateId,
principal: { entityType: 'MCP::User', entityId: userId },
resource: { entityType: 'MCP::Tool', entityId: toolGroupResource },
},
},
}));
return policy.policyId!; // store in your database: users.avp_policy_id = policyId
}
// For new tenant batch provisioning — concurrency-limited to avoid AVP throttling
const userIds = ['user-1', 'user-2', /* ... 1000 more */];
const CONCURRENCY = 5;
for (let i = 0; i < userIds.length; i += CONCURRENCY) {
const chunk = userIds.slice(i, i + CONCURRENCY);
const results = await Promise.allSettled(
chunk.map(id => provisionAuthorAccess(id, 'author-tier-read-tools'))
);
results.forEach((r, j) => {
if (r.status === 'rejected') {
console.error(`Failed to provision ${chunk[j]}:`, r.reason);
}
});
}
Updating a template cascades to all tenants immediately
This is the most powerful property of policy templates. Adding a new condition to the access rule — say, restricting Author read access to verified authors only — requires exactly one API call, regardless of how many tenants are on the Author plan:
// One UpdatePolicyTemplate call — all 1,000 linked policies update atomically
await avp.send(new UpdatePolicyTemplateCommand({
policyStoreId,
policyTemplateId: authorReadTemplateId,
description: 'Author-tier read access — verified authors only (updated 2026-10-23)',
statement: `
permit (
principal == ?principal,
action in [MCP::Action::"CallReadTool"],
resource == ?resource
)
when {
?principal has plan &&
?principal has isVerified &&
?principal.plan in ["author", "team", "enterprise"] &&
?principal.isVerified
};
`,
}));
Auditing grants and the delete-linked-first ordering constraint
List all grants under a template to audit which (principal, resource) pairs have been provisioned. This is your compliance query for "who can call Author read tools":
const grants = await avp.send(new ListPoliciesCommand({
policyStoreId,
filter: { policyTemplateId: authorReadTemplateId },
}));
console.log(`${grants.policies?.length ?? 0} grants under Author read template`);
grants.policies?.forEach(p => {
const tl = p.definition?.templateLinked;
console.log(` principal=${tl?.principal?.entityId}, resource=${tl?.resource?.entityId}`);
});
When removing a template, AVP rejects DeletePolicyTemplate if any linked policies still exist — you must delete all linked policies first. This ordering is intentional: AVP does not cascade-delete to prevent accidental mass revocation. The safe deletion sequence:
// 1. List all linked policies
// 2. Delete each linked policy individually
// 3. Then delete the template
const allGrants = await avp.send(new ListPoliciesCommand({
policyStoreId,
filter: { policyTemplateId: authorReadTemplateId },
}));
for (const p of allGrants.policies ?? []) {
await avp.send(new DeletePolicyCommand({ policyStoreId, policyId: p.policyId! }));
}
await avp.send(new DeletePolicyTemplateCommand({
policyStoreId,
policyTemplateId: authorReadTemplateId,
}));
Pattern 4 — The IsAuthorized API: Entities Block, Cognito Federation, BatchIsAuthorized, and Error Handling
IsAuthorized: the entities block is not optional for ABAC
IsAuthorized accepts a principal reference, an action reference, a resource reference, a context map, and an optional entities block. The entities block is required in practice for any Cedar policy that checks principal.plan, resource.tier, or any other entity attribute — without it, Cedar evaluates those conditions against an entity with no attributes, causing every attribute access to produce a type error and every ABAC policy to be skipped. The decision defaults to DENY because no permit policy matches.
import {
VerifiedPermissionsClient,
IsAuthorizedCommand,
type EntityItem,
} from '@aws-sdk/client-verifiedpermissions';
const avp = new VerifiedPermissionsClient({ region: process.env.AWS_REGION! });
async function authorizeToolCall(
userId: string, userPlan: string, userTenantId: string, isVerified: boolean,
toolName: string, toolCategory: string, toolTier: string, toolTenantId: string,
clientIp?: string,
): Promise<void> {
const principalEntity: EntityItem = {
identifier: { entityType: 'MCP::User', entityId: userId },
attributes: {
plan: { string: userPlan },
tenantId: { string: userTenantId },
isVerified: { boolean: isVerified },
},
parents: [],
};
const resourceEntity: EntityItem = {
identifier: { entityType: 'MCP::Tool', entityId: toolName },
attributes: {
category: { string: toolCategory },
tier: { string: toolTier },
tenantId: { string: toolTenantId },
},
parents: [],
};
const result = await avp.send(new IsAuthorizedCommand({
policyStoreId: process.env.AVP_POLICY_STORE_ID!,
principal: { entityType: 'MCP::User', entityId: userId },
action: { actionType: 'MCP::Action', actionId: 'CallTool' },
resource: { entityType: 'MCP::Tool', entityId: toolName },
entities: { entityList: [principalEntity, resourceEntity] },
context: {
contextMap: {
requestedAt: { string: new Date().toISOString() },
...(clientIp ? { clientIp: { string: clientIp } } : {}),
},
},
}));
// Log non-fatal evaluation errors — usually indicates absent-attribute conditions
if (result.errors?.length) {
console.warn('AVP evaluation errors:', result.errors.map(e => e.errorDescription));
}
if (result.decision !== 'ALLOW') {
throw new Error(`Tool call denied: ${toolName}`);
}
}
One detail about the response: result.determiningPolicies is only populated when the decision is ALLOW. It contains the policyId(s) whose permit clauses matched. For DENY decisions, there are two cases: if a forbid policy matched, it appears in determiningPolicies; if no policy matched at all (default deny), determiningPolicies is empty. Use this distinction for debugging — an empty determiningPolicies on a DENY means the principal is missing a permit policy, not that they are suspended.
IsAuthorizedWithToken: Cognito JWT federation
When users authenticate through Cognito User Pools, IsAuthorizedWithToken lets AVP parse the JWT claims directly rather than requiring you to re-construct the principal entity from scratch. The prerequisite: configure a Cognito identity source on the policy store before calling IsAuthorizedWithToken. Without the identity source, AVP returns a ResourceNotFoundException or a validation error — not a clear message about the missing configuration.
import { CreateIdentitySourceCommand, IsAuthorizedWithTokenCommand }
from '@aws-sdk/client-verifiedpermissions';
// One-time setup — configure the policy store to trust your Cognito User Pool
await avp.send(new CreateIdentitySourceCommand({
policyStoreId: process.env.AVP_POLICY_STORE_ID!,
configuration: {
cognitoUserPoolConfiguration: {
userPoolArn: `arn:aws:cognito-idp:${process.env.AWS_REGION}:${process.env.AWS_ACCOUNT_ID}:userpool/${process.env.COGNITO_USER_POOL_ID}`,
clientIds: [process.env.COGNITO_APP_CLIENT_ID!],
groupConfiguration: {
groupEntityType: 'MCP::Group', // Cognito groups → MCP::Group entities
},
},
},
principalEntityType: 'MCP::User', // JWT sub → MCP::User entity ID
}));
// Per-request call — pass the Cognito ID token directly
async function isToolCallAllowedWithToken(
idToken: string,
toolName: string, toolCategory: string, toolTier: string,
): Promise<boolean> {
const result = await avp.send(new IsAuthorizedWithTokenCommand({
policyStoreId: process.env.AVP_POLICY_STORE_ID!,
identityToken: idToken, // ID token — not access token (use one or the other)
action: { actionType: 'MCP::Action', actionId: 'CallTool' },
resource: { entityType: 'MCP::Tool', entityId: toolName },
// Pass the Tool entity with its attributes — the User entity is inferred from the token
// Caution: inline entity attributes override token claims for the same attribute name
entities: {
entityList: [{
identifier: { entityType: 'MCP::Tool', entityId: toolName },
attributes: { category: { string: toolCategory }, tier: { string: toolTier } },
parents: [],
}],
},
}));
return result.decision === 'ALLOW';
}
Two JWT token choices: use the ID token when Cedar policies check user identity attributes (email, sub, Cognito group membership, custom attributes). Use the access token when policies check Cognito scopes. Cognito custom attributes are stored with the custom: prefix in the token — AVP strips this prefix when mapping to Cedar entity attributes. So custom:plan in the JWT becomes principal.plan in Cedar. Verify this mapping before writing policies that depend on custom attributes, because if AVP doesn't see the attribute on the entity after token parsing, the has guard prevents a type error but the condition evaluates to false.
BatchIsAuthorized: pre-filtering the tool list at session start
BatchIsAuthorized accepts up to 30 individual IsAuthorized requests in one API call. The primary use case for MCP servers is session-start tool filtering: before returning the tool list to the calling agent, batch-check all tools against the current user and return only the ones the user can actually call. This prevents the agent from discovering denied tools mid-session and avoids unnecessary per-tool authorization latency during the session itself.
import { BatchIsAuthorizedCommand } from '@aws-sdk/client-verifiedpermissions';
async function getAuthorizedTools(
userId: string, userPlan: string, userTenantId: string, isVerified: boolean,
allTools: Array<{ name: string; category: string; tier: string; tenantId: string; description: string }>,
) {
const MAX_BATCH = 30;
const authorizedTools = [];
for (let i = 0; i < allTools.length; i += MAX_BATCH) {
const chunk = allTools.slice(i, i + MAX_BATCH);
const response = await avp.send(new BatchIsAuthorizedCommand({
policyStoreId: process.env.AVP_POLICY_STORE_ID!,
// entities shared across all requests in the batch — passed once
entities: {
entityList: [
{
identifier: { entityType: 'MCP::User', entityId: userId },
attributes: {
plan: { string: userPlan }, tenantId: { string: userTenantId },
isVerified: { boolean: isVerified },
},
parents: [],
},
...chunk.map(tool => ({
identifier: { entityType: 'MCP::Tool', entityId: tool.name },
attributes: {
category: { string: tool.category }, tier: { string: tool.tier },
tenantId: { string: tool.tenantId },
},
parents: [],
})),
],
},
requests: chunk.map(tool => ({
principal: { entityType: 'MCP::User', entityId: userId },
action: { actionType: 'MCP::Action', actionId: 'CallTool' },
resource: { entityType: 'MCP::Tool', entityId: tool.name },
})),
}));
const allowed = chunk.filter((_, idx) =>
response.results?.[idx]?.decision === 'ALLOW'
);
authorizedTools.push(...allowed);
}
return authorizedTools;
}
Performance and caching strategy
IsAuthorized is 3–10ms at p50 with low jitter. For an MCP server processing high-throughput tool calls, the latency is acceptable per-call, but you can reduce it further with a per-session LRU cache:
- Cache
ALLOWdecisions only. Never cacheDENYdecisions — a suspended user's next call must hit AVP to enforce immediate revocation. AnALLOWdecision is safe to cache because permit policies don't change under the caller's feet mid-session. - TTL of 30 seconds. Short enough that a newly suspended user is blocked within one billing cycle; long enough to absorb the burst of repeated tool calls during a multi-step agent session.
- Cache key:
`${userId}:${toolName}:${actionId}`. Include the actionId if you use multiple action types; omit it if all calls useCallTool.
import { LRUCache } from 'lru-cache';
const authzCache = new LRUCache<string, boolean>({
max: 1000,
ttl: 30_000, // 30 seconds
});
async function authorizeWithCache(
userId: string, toolName: string, /* ...other attrs */
): Promise<boolean> {
const cacheKey = `${userId}:${toolName}`;
const cached = authzCache.get(cacheKey);
if (cached !== undefined) return cached;
const result = await callIsAuthorized(userId, toolName, /* ... */);
if (result.decision === 'ALLOW') {
authzCache.set(cacheKey, true); // cache ALLOWs only
}
// DENY is never cached — always re-evaluate on next call
return result.decision === 'ALLOW';
}
Failure Modes Table
| Failure | Symptom | Root cause | Fix |
|---|---|---|---|
| Empty namespace in Cedar schema | All CreatePolicy calls fail with schema validation error after PutSchema |
The namespace key in the schema JSON is an empty string "" |
Call PutSchema again with a non-empty namespace (e.g., "MCP"); existing policies referencing the wrong namespace must be deleted and re-created |
| New tool silently denied for all callers | Tool added to server, returns DENY for every user, no error in AVP CloudTrail |
AVP is deny-by-default — a permit policy for the new tool was never created | Create a permit policy for the tool's entity type and tier; use BatchIsAuthorized in a smoke test after deploying new tools |
| ABAC policy silently skipped | User with correct plan attribute still denied; determiningPolicies is empty on DENY |
Cedar policy reads an attribute absent from the entity in the entities block — type error causes policy to be skipped |
Guard every attribute access with has; run cedar validate locally; inspect result.errors[] for evaluationErrors during debugging |
| Forbid cannot be overridden | Special admin user denied even though a broad permit policy exists | A forbid policy matched the request — forbid beats permit unconditionally in Cedar |
Remove the forbid policy to restore access; you cannot add a higher-priority permit; design suspension flows as forbid-based, not permit-removal-based |
IsAuthorizedWithToken returns ResourceNotFoundException |
API call fails immediately, not a Cedar evaluation error | No Cognito identity source configured on the policy store — IsAuthorizedWithToken requires this setup step before accepting tokens |
Call CreateIdentitySource with the Cognito User Pool ARN and client IDs; confirm the principalEntityType matches the entity type in your Cedar schema |
| Cognito custom attribute not visible in Cedar | Condition on principal.plan fails for users authenticated via Cognito token |
custom:plan in Cognito token is stripped to plan — but Cedar policies authored before confirming this mapping used wrong attribute name, or the attribute was not added to the Cognito User Pool schema |
Verify custom attribute presence in decoded ID token; confirm AVP strips custom: prefix in identity source config; use has plan guard to avoid type errors on users with missing attributes |
| Template delete fails with DependencyViolationException | DeletePolicyTemplate returns an error about existing linked policies |
One or more template-linked policies still reference the template; AVP refuses to cascade-delete | Use ListPolicies(filter: { policyTemplateId }) to find all linked policies, delete each individually with DeletePolicy, then retry DeletePolicyTemplate |
| Policy store regional isolation | Multi-region MCP deployment returns DENY from one region despite correct policies in another | AVP policy stores are regional and not automatically replicated — each region has independent policy state | Create policy stores in each region; use an event-driven sync (EventBridge + Lambda) to replicate policy changes across regions on CreatePolicy / UpdatePolicy events; or use a centralized policy store with cross-region AVP client calls (adds ~10–50ms cross-region latency) |
End-to-End AVP Authorization Architecture for AliveMCP
AliveMCP uses two policy templates and a layered authorization model that covers the four access tiers:
Template 1 — Author-tier read access: All Author and above users can call any read category tool. The template binds ?principal to each user entity and ?resource to the author-read-tools resource group. When a user upgrades from Free to Author, the provisioning webhook calls CreatePolicy(TEMPLATE_LINKED) and stores the policyId in the users table. When the subscription expires, DeletePolicy(policyId) revokes access immediately on the next tool call.
Template 2 — Team-tier write access: Team and Enterprise users can call write category tools within their own tenant. The template uses a when { ?principal.tenantId == ?resource.tenantId } condition — the tenant isolation is baked into the template, so a user cannot call write tools on another tenant's resources even if their plan tier permits write access in general.
Static forbid policies for suspension: When AliveMCP detects an abusive API key (rate limit exceeded, fraud signals), the alert worker creates a static forbid policy for that user entity. The forbid takes effect on the next tool call — no user restart required, no deployment required. When the suspension is lifted, deleting the forbid policy restores access to whatever the user's permit policies allow.
// The four-call authorization flow at MCP session start
async function initializeMcpSession(userId: string, jwtToken: string) {
// 1. Verify the JWT and extract Cognito claims
const claims = await verifyCognitoToken(jwtToken);
// 2. BatchIsAuthorize all available tools — return only the accessible subset
const allTools = await loadToolRegistry();
const authorizedTools = await getAuthorizedTools(
claims.sub, claims['custom:plan'], claims['custom:tenantId'],
claims['custom:isVerified'] === 'true',
allTools,
);
// 3. Return filtered tool list to the agent — agent only sees what it can call
return { tools: authorizedTools.map(t => ({ name: t.name, description: t.description })) };
}
// Per-tool call: IsAuthorized from the tool dispatcher
async function dispatchToolCall(toolName: string, args: unknown, session: SessionContext) {
await authorizeToolCall(
session.userId, session.plan, session.tenantId, session.isVerified,
toolName, getToolCategory(toolName), getToolTier(toolName), getTenantId(toolName),
session.clientIp,
);
return await executeTool(toolName, args);
}
The performance profile for AliveMCP at current scale: session-start BatchIsAuthorized covers ~15 tools in a single API call (one batch, under the 30-call limit), adding ~8ms to session initialization. Per-tool IsAuthorized adds ~5ms per call, reduced to <1ms for the 70% of calls that hit the 30-second LRU cache within a session. Total authorization overhead is under 20ms per 10-minute agent session at p95.
AliveMCP monitors for AVP evaluation errors in the result.errors[] array using CloudWatch Logs Insights. Any session with more than zero evaluation errors triggers an alert — it usually indicates a Cedar policy referencing an attribute that isn't populated in the entities block, which means the policy is being silently skipped and the authorization model has a gap. Finding these gaps before a free-tier user discovers they can access a paid tool is the purpose of the alert.
Related Guides
- AWS Verified Permissions for MCP Servers — Cedar Authorization, Policy Stores — policy store creation with
clientTokenidempotency, STRICT vs OFF validation mode, Cedar schema withmemberOfTypesfor group hierarchy, five-row failure modes table covering schema validation failures, ValidationException, ResourceNotFoundException wrong region, soft attribute type errors, and ThrottlingException 500 TPS quota - Cedar Policy Language for MCP Server Authorization — Permit, Forbid, ABAC — full policy grammar, ABAC patterns for tier-gating and tenant isolation, absent-attribute type errors, the
hasoperator guard, forbid policies for suspension and revocation, action groups with transitiveinmatching, Cedar CLI local testing for CI - AWS Verified Permissions Policy Templates for Multi-Tenant MCP Servers — static vs template-linked policy comparison table,
CreatePolicyTemplatewith?principaland?resourceplaceholders, batch tenant provisioning with concurrency control,UpdatePolicyTemplateatomic cascade,ListPoliciesbypolicyTemplateIdfor compliance audit, delete-linked-first ordering constraint - AVP IsAuthorized and IsAuthorizedWithToken API for MCP Servers —
IsAuthorizedfull request withentitiesblock,determiningPolicieson ALLOW vs DENY semantics,IsAuthorizedWithTokenCognito federation with identity source setup,BatchIsAuthorizedsession-start tool filtering, 3–10ms p50 performance, LRU ALLOW-only cache TTL 30s, error classification
Monitor MCP server authorization health with AliveMCP
AliveMCP pings every public MCP endpoint every 60 seconds and alerts on authorization failures before your users hit them. Our public dashboard shows live status across every registry-listed MCP server — claim your listing to add AVP authorization health checks, custom alert webhooks, and 90-day uptime history.
See pricing View the dashboard