Guide · AWS Verified Permissions · API Patterns

AVP IsAuthorized and IsAuthorizedWithToken API for MCP Servers

AWS Verified Permissions exposes three authorization endpoints: IsAuthorized (pass entities explicitly), IsAuthorizedWithToken (pass a Cognito or OIDC JWT and let AVP parse the principal from claims), and BatchIsAuthorized / BatchIsAuthorizedWithToken (up to 30 checks in one call). Three integration patterns catch teams: IsAuthorizedWithToken requires a Cognito identity source to be configured on the policy store before it will accept tokens — it is not a drop-in replacement for IsAuthorized without the identity source setup; entity attributes in the entities block take precedence over attributes fetched from the identity source — if you pass both a token and an entities block, inline attributes override token claims for the same entity; and the determiningPolicies array in the response is only populated when the decision is ALLOW — for DENY with no matching policy, the array is empty, not populated with the policies that evaluated to non-match.

TL;DR

Use IsAuthorized when you build the principal entity yourself (API-key-based auth, custom JWT claims). Use IsAuthorizedWithToken when users authenticate via Cognito — configure an identity source on the policy store first. Use BatchIsAuthorized to pre-authorize a set of tools at session start (warm the decision cache on the client side). Always check result.errors[] in addition to result.decision — entity attribute type errors are surfaced in errors while the decision is still returned.

IsAuthorized: full request structure

The IsAuthorized call passes principal, action, resource, context, and an optional entities block. The entities block is how you inject the current attribute values of the principal and resource into the evaluation — without it, Cedar conditions that reference principal.plan or resource.tier will fail with attribute-not-found errors.

import {
  VerifiedPermissionsClient,
  IsAuthorizedCommand,
  type EntityItem,
} from '@aws-sdk/client-verifiedpermissions';

const avp = new VerifiedPermissionsClient({ region: process.env.AWS_REGION ?? 'us-east-1' });

interface ToolCallAuthzRequest {
  userId: string;
  userPlan: string;      // "free" | "author" | "team" | "enterprise" | "suspended"
  userTenantId: string;
  isVerified: boolean;
  toolName: string;
  toolCategory: string;  // "read" | "write" | "admin"
  toolTier: string;      // "free" | "paid"
  toolOwnerId?: string;
  clientIp?: string;
}

async function isToolCallAllowed(req: ToolCallAuthzRequest): Promise<{
  allowed: boolean;
  determiningPolicies: string[];
  errors: string[];
}> {
  const principalEntity: EntityItem = {
    identifier: { entityType: 'MCP::User', entityId: req.userId },
    attributes: {
      plan:       { string: req.userPlan },
      tenantId:   { string: req.userTenantId },
      isVerified: { boolean: req.isVerified },
    },
    parents: [],
  };

  const resourceEntity: EntityItem = {
    identifier: { entityType: 'MCP::Tool', entityId: req.toolName },
    attributes: {
      category: { string: req.toolCategory },
      tier:     { string: req.toolTier },
      tenantId: { string: req.userTenantId },  // same tenant scope
      ...(req.toolOwnerId ? { ownerId: { string: req.toolOwnerId } } : {}),
    },
    parents: [],
  };

  const result = await avp.send(new IsAuthorizedCommand({
    policyStoreId: process.env.AVP_POLICY_STORE_ID!,
    principal: { entityType: 'MCP::User', entityId: req.userId },
    action:    { actionType: 'MCP::Action', actionId: 'CallTool' },
    resource:  { entityType: 'MCP::Tool', entityId: req.toolName },
    entities: { entityList: [principalEntity, resourceEntity] },
    context: {
      contextMap: {
        requestedAt: { string: new Date().toISOString() },
        ...(req.clientIp ? { clientIp: { string: req.clientIp } } : {}),
      },
    },
  }));

  return {
    allowed: result.decision === 'ALLOW',
    determiningPolicies: result.determiningPolicies?.map(p => p.policyId!) ?? [],
    errors: result.errors?.map(e => e.errorDescription ?? 'unknown') ?? [],
  };
}

// Usage
const { allowed, errors } = await isToolCallAllowed({
  userId: 'user-alice-123',
  userPlan: 'author',
  userTenantId: 'tenant-abc',
  isVerified: true,
  toolName: 'list_private_endpoints',
  toolCategory: 'read',
  toolTier: 'paid',
});

if (errors.length > 0) {
  console.warn('AVP evaluation errors (non-fatal):', errors);
}
if (!allowed) throw new McpAuthorizationError('Tool call not permitted');

IsAuthorizedWithToken: Cognito JWT federation

When users authenticate through Cognito User Pools, their ID token or access token contains standard JWT claims (sub, cognito:groups, custom attributes). AVP can parse these directly without you re-constructing the principal entity — but requires an identity source configured on the policy store.

Step 1: Create a Cognito identity source

import { CreateIdentitySourceCommand } 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!],
      // Map Cognito groups to Cedar principal entity types
      groupConfiguration: {
        groupEntityType: 'MCP::Group',  // Cognito groups become MCP::Group entities
      },
    },
  },
  principalEntityType: 'MCP::User',  // JWT sub becomes an MCP::User entity ID
}));

Step 2: Call IsAuthorizedWithToken

import { IsAuthorizedWithTokenCommand } from '@aws-sdk/client-verifiedpermissions';

async function isToolCallAllowedWithToken(
  idToken: string,   // Cognito ID token from the request Authorization header
  toolName: string,
  toolCategory: string,
  toolTier: string,
): Promise {
  const result = await avp.send(new IsAuthorizedWithTokenCommand({
    policyStoreId: process.env.AVP_POLICY_STORE_ID!,
    identityToken: idToken,   // OR accessToken — not both
    action: { actionType: 'MCP::Action', actionId: 'CallTool' },
    resource: { entityType: 'MCP::Tool', entityId: toolName },
    // entities block: pass the Tool entity with attributes
    // The User entity is inferred from the token — do not pass it here
    // (if you do, inline attrs override token claims for matching attributes)
    entities: {
      entityList: [{
        identifier: { entityType: 'MCP::Tool', entityId: toolName },
        attributes: {
          category: { string: toolCategory },
          tier:     { string: toolTier },
        },
        parents: [],
      }],
    },
  }));

  return result.decision === 'ALLOW';
}

ID token vs access token: Use the ID token when your Cedar policies check standard user attributes (email, sub, given_name) or Cognito group membership. Use the access token when your policies check Cognito scopes (context.scope). You cannot pass both in a single IsAuthorizedWithToken call — choose one per policy store identity source configuration.

Mapping Cognito custom attributes to Cedar attributes

Cognito custom attributes (prefixed custom: in the token) are mapped to Cedar entity attributes automatically when you use IsAuthorizedWithToken. The prefix is stripped — custom:plan in the JWT becomes plan in the Cedar entity's attribute map. Verify this mapping in the identity source configuration before writing Cedar policies that rely on custom attributes.

// Cognito ID token claims (decoded):
// {
//   "sub": "user-alice-123",
//   "cognito:groups": ["author-tier"],
//   "custom:plan": "author",
//   "custom:tenantId": "tenant-abc",
//   "custom:isVerified": "true"  // Note: Cognito stores custom attrs as strings
// }

// Cedar policy referencing token claims — works with IsAuthorizedWithToken
// The policy store's Cognito identity source maps JWT claims to Cedar attributes:
// principal.plan = "author", principal.tenantId = "tenant-abc"
// However: custom:isVerified is "true" (string), not true (boolean) — use string comparison
permit (
  principal is MCP::User,
  action    == MCP::Action::"CallTool",
  resource  is MCP::Tool
)
when {
  principal has plan &&
  principal.plan in ["author", "team", "enterprise"] &&
  resource.tier == "paid"
};

BatchIsAuthorized: pre-authorizing tool sets

BatchIsAuthorized accepts up to 30 IsAuthorized requests in a single API call. Use it at session start to discover which tools are available to the current user — this lets the MCP server filter its tool list before returning it to the agent, so the agent doesn't waste context on tools it can't call.

import { BatchIsAuthorizedCommand } from '@aws-sdk/client-verifiedpermissions';

interface ToolMetadata {
  name: string;
  category: string;
  tier: string;
  description: string;
}

async function getAuthorizedTools(
  userId: string,
  userPlan: string,
  userTenantId: string,
  isVerified: boolean,
  allTools: ToolMetadata[],
): Promise<ToolMetadata[]> {
  const MAX_BATCH = 30;  // AVP hard limit per BatchIsAuthorized call

  const authorized: ToolMetadata[] = [];

  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: [],
          },
          // Resource entities for all tools in this chunk
          ...chunk.map(tool => ({
            identifier: { entityType: 'MCP::Tool', entityId: tool.name },
            attributes: {
              category: { string: tool.category },
              tier:     { string: tool.tier },
              tenantId: { string: userTenantId },
            },
            parents: [] as any[],
          })),
        ],
      },
      requests: chunk.map(tool => ({
        principal: { entityType: 'MCP::User', entityId: userId },
        action:    { actionType: 'MCP::Action', actionId: 'CallTool' },
        resource:  { entityType: 'MCP::Tool', entityId: tool.name },
      })),
    }));

    response.results?.forEach((result, idx) => {
      if (result.decision === 'ALLOW') {
        authorized.push(chunk[idx]);
      }
    });
  }

  return authorized;
}

// At session start — returns only the tools this user is allowed to call
const visibleTools = await getAuthorizedTools(
  'user-alice-123', 'author', 'tenant-abc', true, ALL_TOOLS
);
// Return visibleTools in the MCP tool list response

Performance: latency budget and caching strategy

PatternLatencyWhen to useTrade-offs
Single IsAuthorized per tool call3–10ms p50, <25ms p99Default for most MCP servers — each tool call is independently authorizedOne network round-trip per call; negligible vs. tool execution time
BatchIsAuthorized at session start5–15ms for 30 requestsWhen the agent needs to know which tools are available before picking oneOne batch call upfront; stale if plan changes mid-session (rare)
In-process cache (30s TTL)<0.1ms for cached hitHigh-frequency tool calls from the same user in the same sessionCache invalidation on plan change requires explicit eviction; accept 30s stale window
IsAuthorizedWithToken with Cognito8–20ms (includes JWT signature verification)Cognito-authenticated users; avoid re-parsing principal from JWT claims yourselfRequires identity source setup; token expiry must be handled
import { LRUCache } from 'lru-cache';  // npm install lru-cache

// In-process decision cache — cache ALLOW decisions only (never cache DENY)
// DENY must always be re-checked in case a new permit was added
const authzCache = new LRUCache<string, boolean>({
  max: 1000,       // max 1000 cached (user, tool) pairs
  ttl: 30_000,     // 30-second TTL — accept up to 30s stale for plan upgrades
});

async function authorizeWithCache(req: ToolCallAuthzRequest): Promise<void> {
  const cacheKey = `${req.userId}:${req.toolName}`;
  const cached = authzCache.get(cacheKey);

  if (cached === true) return;  // cache hit — allowed
  // cached === false: not in cache (false means absent in LRU) OR was denied
  // Either way, re-check AVP

  const { allowed, errors } = await isToolCallAllowed(req);

  if (errors.length > 0) console.warn('AVP errors:', errors);

  if (!allowed) throw new McpAuthorizationError('Tool call not permitted');

  // Only cache ALLOW — DENY is not cached (new permit policy may be added)
  authzCache.set(cacheKey, true);
}

// On plan change (billing webhook):
function invalidateUserCache(userId: string): void {
  // LRU doesn't support prefix delete — track keys per user in a separate set
  // For simplicity, flush the whole cache on any plan change event
  authzCache.clear();
}

Handling errors vs policy misses

The IsAuthorized response distinguishes two types of non-ALLOW outcomes:

const result = await avp.send(new IsAuthorizedCommand(input));

// Always log errors regardless of decision
if (result.errors && result.errors.length > 0) {
  console.error('AVP policy evaluation errors', {
    decision: result.decision,
    errors: result.errors.map(e => ({
      code: e.errorDescription,
    })),
    policyStoreId: process.env.AVP_POLICY_STORE_ID,
    userId: req.userId,
    toolName: req.toolName,
  });
}

if (result.decision !== 'ALLOW') {
  // Distinguish "no policy" from "hard forbid" for better error messages
  const hasForbid = result.determiningPolicies?.some(
    p => p.policyType === 'STATIC' // or TEMPLATE_LINKED
  );
  // Note: determiningPolicies is populated for ALLOW; for DENY it's typically empty
  // unless a forbid matched (in which case the forbid policy IS listed)
  throw new McpAuthorizationError(
    result.decision === 'DENY'
      ? 'Access denied — check your plan or contact support'
      : 'Authorization check failed'
  );
}

How AliveMCP wires the authorization API

AliveMCP's MCP server (Node.js + @modelcontextprotocol/sdk) wraps every tool handler with a single authorizeToolCall middleware that fires before the tool's own logic. The middleware calls IsAuthorized with the user's decoded JWT claims (user ID, plan, tenant, verified flag) and the tool's static metadata (category, tier) from a startup-time registry. The IsAuthorized result is cached in a 30-second LRU cache keyed by ${userId}:${toolName}. When the billing webhook fires a plan-change event, the LRU cache is cleared. The latency budget for the auth layer is 10ms p99 — in practice, cold AVP calls from the same AWS region average 5ms and cached calls are sub-millisecond. The AliveMCP status page at alivemcp.com/status/<slug> monitors AVP's IsAuthorized latency via CloudWatch as a synthetic probe — a latency spike is treated as an uptime event and fires an alert to the Author's webhook.

Related guides