Guide · AWS · Authorization
AWS Verified Permissions for MCP Servers
AWS Verified Permissions (AVP) is a managed authorization service that evaluates Cedar policies against each incoming request — you define principals (users, agents, API keys), actions (tool names), and resources (MCP endpoints, datasets, databases) in a Cedar schema, then call IsAuthorized before executing each MCP tool call. Three things catch teams early: AVP policy stores are regional and not replicated, so multi-region deployments need a policy store per region or an active-active replication strategy; the Cedar schema namespace is required and must be a non-empty string — an empty namespace causes all CreatePolicy calls to fail with a schema validation error; and IsAuthorized returns DENY by default when no policy matches — the service is deny-by-default, so forgetting to create a permit policy for a new tool silently blocks all callers until you notice.
TL;DR
Create a policy store in the console or via CloudFormation. Define a Cedar schema with at least three entity types: User (or APIKey), Tool, and Session. Write permit policies in Cedar syntax. Call IsAuthorized from your MCP server's tool dispatcher before executing any tool. Deny is the default — every tool needs an explicit permit. For Cognito-authenticated callers, use IsAuthorizedWithToken to avoid re-building the principal entity from JWT claims yourself.
Why add authorization at the MCP server layer
An MCP server that skips per-tool authorization relies entirely on its calling client (Claude Desktop, a custom agent) to enforce access rules — but the client is not a trust boundary you control. Common scenarios that require server-side authorization:
- Multi-tenant servers: Tool A queries a customer's private database; Tool B queries a shared read-only dataset. Different tenants should not cross the boundary, but the agent orchestrator doesn't know which tools are private.
- Tiered access plans: Free-plan users get read-only tools; paid users get write tools. The billing system knows the plan, but the agent doesn't.
- Audit requirements: Some enterprise customers require a policy-as-code record of which principals can call which tools, with an immutable log. IAM policies alone don't model tool-level granularity.
- Runtime policy changes: An operator revokes a compromised API key. The change must take effect on the next tool call — within seconds — without redeploying the MCP server.
AVP handles all four. Policies are evaluated per request in <10ms, changes propagate immediately, and CloudTrail logs every authorization decision.
Core concepts: policy store, schema, entities, policies
| Concept | What it is | In MCP terms |
|---|---|---|
| Policy store | Container for all Cedar policies and the schema; has a unique ARN | One per MCP server (or one per environment: dev/staging/prod) |
| Cedar schema | JSON document defining entity types, attributes, actions, and action groups | Defines User, Tool, Session, and which actions each can participate in |
| Entity | A runtime object (principal or resource) with an entity type and attributes; passed in the IsAuthorized call or stored in AVP itself | { entityType: "MCP::User", entityId: "user-123", attributes: { plan: "team" } } |
| Static policy | A Cedar policy with hard-coded principal and resource | A single user's explicit grant to a single tool |
| Template-linked policy | Instantiated from a policy template with a specific principal/resource substituted | One template for "Author tier users can call read tools" — one linked policy per Author user |
Creating a policy store
Policy stores can be created via the console, CDK/CloudFormation, or the AWS SDK. For an MCP server, create one policy store per environment. The validationSettings field controls whether AVP validates policies against the schema on creation — use STRICT to catch Cedar typos at policy-write time rather than authorization time.
import {
VerifiedPermissionsClient,
CreatePolicyStoreCommand,
PutSchemaCommand,
} from '@aws-sdk/client-verifiedpermissions';
const avp = new VerifiedPermissionsClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
// Create the policy store (idempotent via clientToken)
const store = await avp.send(new CreatePolicyStoreCommand({
validationSettings: {
mode: 'STRICT', // validates Cedar policies against schema on write; use OFF for rapid iteration
},
clientToken: 'mcp-server-prod-v1', // idempotent — safe to re-run during CDK deploy
description: 'AliveMCP production tool authorization',
}));
const policyStoreId = store.policyStoreId!;
console.log('Policy store ID:', policyStoreId);
// Store this in SSM Parameter Store or env var — needed for every IsAuthorized call
The policyStoreId is not a secret but it should be stored in a parameter so the MCP server can read it at startup. A sensible SSM path: /alivemcp/prod/avp-policy-store-id.
Cedar schema design for MCP tool authorization
The Cedar schema defines the types that your policies can reference. For an MCP server, you need at minimum: a principal type (who is calling), an action (which tool), and a resource type (which capability the tool accesses). AVP requires at least one entity type in the schema namespace.
// Cedar schema in JSON format — passed to PutSchemaCommand
const mcpSchema = {
"MCP": {
"entityTypes": {
"User": {
"shape": {
"type": "Record",
"attributes": {
"plan": { "type": "String" }, // "free" | "author" | "team" | "enterprise"
"tenantId": { "type": "String" },
"isVerified": { "type": "Boolean" }
}
},
"memberOfTypes": ["Group"]
},
"Group": {
"shape": {
"type": "Record",
"attributes": {}
}
},
"Tool": {
"shape": {
"type": "Record",
"attributes": {
"category": { "type": "String" }, // "read" | "write" | "admin"
"tier": { "type": "String" } // "free" | "paid" — minimum tier required
}
}
},
"Session": {
"shape": {
"type": "Record",
"attributes": {
"createdAt": { "type": "String" },
"agentId": { "type": "String" }
}
}
}
},
"actions": {
"CallTool": {
"appliesTo": {
"principalTypes": ["User"],
"resourceTypes": ["Tool"],
"context": {
"type": "Record",
"attributes": {
"requestedAt": { "type": "String" },
"clientIp": { "type": "String" }
}
}
}
},
"ReadStatus": {
"appliesTo": {
"principalTypes": ["User"],
"resourceTypes": ["Tool"]
}
}
}
}
};
await avp.send(new PutSchemaCommand({
policyStoreId,
definition: {
cedarJson: JSON.stringify(mcpSchema),
},
}));
Key design decisions in this schema:
- Namespace "MCP" — must be non-empty. Use a short, stable name. Entity references in policies must be qualified:
MCP::User::"user-123". - User is a member of Group — the
memberOfTypesfield enables Cedar'sinoperator for group-based policies:permit(principal in MCP::Group::"team-tier", ...). - Tool.tier attribute — lets you write attribute-based policies like
when { resource.tier == "free" || principal.plan in ["team","enterprise"] }instead of creating one policy per tool.
Writing your first Cedar policies
Cedar uses a functional, readable syntax. Policies evaluate to permit or forbid. AVP is deny-by-default — a request is denied unless at least one permit policy matches and no forbid policy matches.
import { CreatePolicyCommand } from '@aws-sdk/client-verifiedpermissions';
// Policy 1: Free-tier users can call any "free" tool
const freeTierPolicy = await avp.send(new CreatePolicyCommand({
policyStoreId,
definition: {
static: {
description: 'Free plan users can call free-tier tools',
statement: `
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
principal.plan == "free" &&
resource.tier == "free"
};
`,
},
},
}));
// Policy 2: Author/Team/Enterprise users can call all tools except admin tools
const paidTierPolicy = await avp.send(new CreatePolicyCommand({
policyStoreId,
definition: {
static: {
description: 'Paid plan users can call free and paid tools',
statement: `
permit (
principal is MCP::User,
action == MCP::Action::"CallTool",
resource is MCP::Tool
)
when {
principal.plan in ["author", "team", "enterprise"] &&
resource.category != "admin"
};
`,
},
},
}));
// Policy 3: Hard forbid on a specific suspended user — overrides any permit
const suspendedUserForbid = await avp.send(new CreatePolicyCommand({
policyStoreId,
definition: {
static: {
description: 'Suspended user blocked from all tools',
statement: `
forbid (
principal == MCP::User::"suspended-user-456",
action,
resource
);
`,
},
},
}));
Forbid beats permit — if both a permit and a forbid match a request, AVP returns DENY. This is the right model for suspension and emergency revocation: add a forbid policy and it immediately blocks the principal across all tools, with no need to update existing permit policies.
Calling IsAuthorized from the MCP tool dispatcher
Every tool call in your MCP server should pass through a single authorize function before execution. The function constructs the AVP request from the caller's identity and the tool's metadata.
import {
IsAuthorizedCommand,
type IsAuthorizedCommandInput,
} from '@aws-sdk/client-verifiedpermissions';
interface AuthzContext {
userId: string;
userPlan: string;
userTenantId: string;
isVerified: boolean;
toolName: string;
toolCategory: string; // "read" | "write" | "admin"
toolTier: string; // "free" | "paid"
clientIp?: string;
}
async function authorizeToolCall(ctx: AuthzContext): Promise {
const input: IsAuthorizedCommandInput = {
policyStoreId: process.env.AVP_POLICY_STORE_ID!,
principal: {
entityType: 'MCP::User',
entityId: ctx.userId,
},
action: {
actionType: 'MCP::Action',
actionId: 'CallTool',
},
resource: {
entityType: 'MCP::Tool',
entityId: ctx.toolName,
},
// entities block: pass the current attribute values inline
// AVP merges these with any entities stored in the policy store
entities: {
entityList: [
{
identifier: { entityType: 'MCP::User', entityId: ctx.userId },
attributes: {
plan: { string: ctx.userPlan },
tenantId: { string: ctx.userTenantId },
isVerified: { boolean: ctx.isVerified },
},
},
{
identifier: { entityType: 'MCP::Tool', entityId: ctx.toolName },
attributes: {
category: { string: ctx.toolCategory },
tier: { string: ctx.toolTier },
},
},
],
},
context: {
contextMap: {
requestedAt: { string: new Date().toISOString() },
...(ctx.clientIp ? { clientIp: { string: ctx.clientIp } } : {}),
},
},
};
const result = await avp.send(new IsAuthorizedCommand(input));
if (result.decision !== 'ALLOW') {
// Log determining policies (which policies matched, if any)
const determining = result.determiningPolicies?.map(p => p.policyId).join(', ') ?? 'none';
throw new Error(`Tool call denied. Determining policies: ${determining}`);
}
}
// Usage in MCP tool dispatcher:
// await authorizeToolCall({ userId, userPlan, ..., toolName: 'list_servers', toolTier: 'free' });
// const result = await listServers(params);
Failure modes table
| Failure | AVP response | Root cause | Fix |
|---|---|---|---|
| All requests denied despite correct policy | DENY, determiningPolicies: [] | Schema validation failure; policies failing schema check are stored but never evaluated | Check policy store validation mode; run GetPolicy to see validation errors |
ValidationException on CreatePolicy | HTTP 400 | Cedar syntax error or entity type not in schema | Validate policy with Cedar CLI before upload; check namespace prefix matches schema |
ResourceNotFoundException on IsAuthorized | HTTP 404 | Wrong policyStoreId or wrong region | Verify env var; policy stores are regional — wrong region = not found |
Errors in result.errors[] but decision still returned | ALLOW or DENY | Entity attribute type mismatch (e.g., passing a boolean as a string); AVP degrades gracefully | Fix entity attribute types in entities.entityList to match schema |
ThrottlingException | HTTP 429 | Default quota: 500 TPS per policy store per region | Request quota increase via Service Quotas; add exponential backoff with jitter |
How AliveMCP uses Verified Permissions
AliveMCP's MCP server exposes tools such as get_server_status (free), list_private_endpoints (team tier), and export_sla_report (enterprise tier). Every inbound tool call passes through a shared authorizeToolCall function backed by a single AVP policy store. The tool metadata (category, tier) is stored statically in a tool registry at startup — no database lookup per call. The user's plan attribute is set when the user authenticates via Cognito and is cached in the JWT claims, so the entity block in each IsAuthorized request is built from the decoded token without a separate database round-trip.
When an Author-tier user downgrades to the Free plan, the billing webhook calls UpdatePolicy (or simply updates the entity in AVP's entity store) so the change takes effect on the user's next tool call within seconds — no server restart, no cache invalidation required.
AliveMCP monitors the policy store's health indirectly: if the AVP IsAuthorized call latency exceeds 50ms p99 (measured via CloudWatch), an alert fires because legitimate authorization decisions normally complete in under 10ms. A spike indicates either quota pressure or a policy evaluation loop from an overly complex Cedar condition.