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:

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

ConceptWhat it isIn MCP terms
Policy storeContainer for all Cedar policies and the schema; has a unique ARNOne per MCP server (or one per environment: dev/staging/prod)
Cedar schemaJSON document defining entity types, attributes, actions, and action groupsDefines User, Tool, Session, and which actions each can participate in
EntityA 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 policyA Cedar policy with hard-coded principal and resourceA single user's explicit grant to a single tool
Template-linked policyInstantiated from a policy template with a specific principal/resource substitutedOne 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:

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

FailureAVP responseRoot causeFix
All requests denied despite correct policyDENY, determiningPolicies: []Schema validation failure; policies failing schema check are stored but never evaluatedCheck policy store validation mode; run GetPolicy to see validation errors
ValidationException on CreatePolicyHTTP 400Cedar syntax error or entity type not in schemaValidate policy with Cedar CLI before upload; check namespace prefix matches schema
ResourceNotFoundException on IsAuthorizedHTTP 404Wrong policyStoreId or wrong regionVerify env var; policy stores are regional — wrong region = not found
Errors in result.errors[] but decision still returnedALLOW or DENYEntity attribute type mismatch (e.g., passing a boolean as a string); AVP degrades gracefullyFix entity attribute types in entities.entityList to match schema
ThrottlingExceptionHTTP 429Default quota: 500 TPS per policy store per regionRequest 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.

Related guides