Guide · AWS Cognito · Groups and RBAC
AWS Cognito User Groups and RBAC in MCP Servers — Group Claims, IAM Role Mapping, Priority
Cognito user groups provide a lightweight RBAC layer built directly into your user pool — no separate roles service required. Groups appear as a cognito:groups string array in the ID token's JWT claims. An MCP server validates the bearer token, reads the group list, and gates tool access accordingly — no database query needed per request. Groups can also be mapped to IAM roles via Cognito Identity Pools, so a user in the admin group can assume an IAM role with broader AWS permissions when calling AWS APIs through the MCP server's tool handlers. Group priority determines which role is assumed when a user belongs to multiple groups. This guide covers group lifecycle management (create, assign, remove), reading group claims in an MCP tool handler, priority-based role resolution, and patterns for tiered plan enforcement via groups.
TL;DR
Create groups with CreateGroupCommand (optionally with an RoleArn for identity pool mapping). Add users with AdminAddUserToGroupCommand. Groups appear in the ID token as cognito:groups: ['admin', 'team']. Read the claim in your MCP token validator and gate tool calls based on group membership. When a user belongs to multiple groups with different IAM roles, Cognito identity pools pick the role from the group with the numerically lowest Precedence value (lower = higher priority). Update group membership with AdminRemoveUserFromGroupCommand — changes take effect on the next token issuance (existing tokens are unaffected until refresh).
Creating and managing groups
import {
CognitoIdentityProviderClient,
CreateGroupCommand,
AdminAddUserToGroupCommand,
AdminRemoveUserFromGroupCommand,
AdminListGroupsForUserCommand,
ListUsersInGroupCommand,
} from '@aws-sdk/client-cognito-identity-provider';
const cognito = new CognitoIdentityProviderClient({ region: 'us-east-1' });
// Create plan-tier groups (Precedence = priority for IAM role selection; lower wins)
await Promise.all([
cognito.send(new CreateGroupCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
GroupName: 'enterprise',
Description: 'Enterprise plan — unlimited private endpoints, SSO, audit log',
Precedence: 1, // highest priority for IAM role selection
RoleArn: 'arn:aws:iam::123456789012:role/CognitoEnterpriseRole',
})),
cognito.send(new CreateGroupCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
GroupName: 'team',
Description: 'Team plan — 10 private endpoints, Slack alerts',
Precedence: 10,
RoleArn: 'arn:aws:iam::123456789012:role/CognitoTeamRole',
})),
cognito.send(new CreateGroupCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
GroupName: 'author',
Description: 'Author plan — claim listing, custom alerts',
Precedence: 20,
RoleArn: 'arn:aws:iam::123456789012:role/CognitoAuthorRole',
})),
cognito.send(new CreateGroupCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
GroupName: 'free',
Description: 'Free tier — read-only public dashboard access',
Precedence: 100,
// No RoleArn — free users get the unauthenticated identity pool role
})),
]);
// Assign a user to a group (e.g., after Stripe payment confirmed)
await cognito.send(new AdminAddUserToGroupCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
Username: 'user-sub-uuid', // Cognito sub (UUID) or username
GroupName: 'author',
}));
// List all groups a user belongs to
const { Groups } = await cognito.send(new AdminListGroupsForUserCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
Username: 'user-sub-uuid',
}));
console.log(Groups?.map(g => g.GroupName)); // ['author', 'free']
Group membership changes take effect on the next token issuance — existing access tokens and ID tokens remain valid until they expire and are refreshed. For immediate revocation (e.g., downgrade enforcement after subscription cancellation), also call AdminUserGlobalSignOutCommand to invalidate all refresh tokens, forcing the user through sign-in and picking up the updated group membership.
Reading group claims in an MCP tool handler
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const JWKS = createRemoteJWKSet(
new URL('https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX/.well-known/jwks.json')
);
interface AuthContext {
userId: string;
email: string;
groups: string[];
plan: 'enterprise' | 'team' | 'author' | 'free';
}
async function extractAuthContext(authHeader: string | undefined): Promise<AuthContext> {
if (!authHeader?.startsWith('Bearer ')) {
throw new Error('Missing or invalid Authorization header');
}
const token = authHeader.slice(7);
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX',
audience: process.env.COGNITO_CLIENT_ID,
});
const groups = (payload['cognito:groups'] as string[] | undefined) ?? [];
// Determine highest plan from group membership
let plan: AuthContext['plan'] = 'free';
if (groups.includes('enterprise')) plan = 'enterprise';
else if (groups.includes('team')) plan = 'team';
else if (groups.includes('author')) plan = 'author';
return {
userId: payload.sub as string,
email: payload.email as string,
groups,
plan,
};
}
// Group-gated MCP tool: only 'team' and 'enterprise' users can monitor private endpoints
const server = new McpServer({ name: 'alivemcp-api', version: '1.0.0' });
server.tool('add_private_endpoint', {
url: z.string().url(),
name: z.string().max(64),
}, async ({ url, name }, context) => {
const auth = await extractAuthContext(context.requestContext?.headers?.authorization);
if (!['team', 'enterprise'].includes(auth.plan)) {
return {
content: [{
type: 'text',
text: JSON.stringify({
error: 'Private endpoint monitoring requires a Team or Enterprise plan',
current_plan: auth.plan,
upgrade_url: 'https://alivemcp.com/#pricing',
}),
}],
};
}
// Proceed with adding the private endpoint for authorized users
return {
content: [{ type: 'text', text: JSON.stringify({ status: 'ok', endpoint: url }) }],
};
});
The cognito:groups claim is an array of group names — not a single string. A user may belong to multiple groups simultaneously (e.g., ['team', 'beta-testers']). Check group membership with Array.includes() or Set.has(). When implementing tiered plans, check groups in priority order: check enterprise before team before author, so a user who is in both enterprise and author gets the higher-tier access.
Identity pool IAM role mapping via group precedence
import {
CognitoIdentityClient,
GetCredentialsForIdentityCommand,
} from '@aws-sdk/client-cognito-identity';
// When users need to make direct AWS API calls (e.g., upload to their S3 prefix),
// Cognito Identity Pools exchange the ID token for temporary AWS credentials.
// The IAM role assigned is determined by the highest-precedence group with a RoleArn.
const identity = new CognitoIdentityClient({ region: 'us-east-1' });
async function getTemporaryCredentials(idToken: string): Promise<{
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
}> {
// Step 1: Get the identity ID
const idPoolId = 'us-east-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
const providerKey = `cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX`;
const { IdentityId } = await identity.send(
new (await import('@aws-sdk/client-cognito-identity')).GetIdCommand({
IdentityPoolId: idPoolId,
Logins: { [providerKey]: idToken },
})
);
// Step 2: Exchange ID + token for temporary AWS credentials
// The IAM role is selected from the user's highest-precedence Cognito group
const { Credentials } = await identity.send(new GetCredentialsForIdentityCommand({
IdentityId: IdentityId!,
Logins: { [providerKey]: idToken },
}));
return {
accessKeyId: Credentials!.AccessKeyId!,
secretAccessKey: Credentials!.SecretKey!,
sessionToken: Credentials!.SessionToken!,
};
}
| Group | Precedence | IAM role | Typical permissions |
|---|---|---|---|
| enterprise | 1 | CognitoEnterpriseRole | read/write S3, CloudWatch, SES |
| team | 10 | CognitoTeamRole | read/write S3 (own prefix), CloudWatch read |
| author | 20 | CognitoAuthorRole | write S3 (own prefix only) |
| free | 100 | (none — unauthenticated role) | read public S3 prefix |
Cognito identity pools select the IAM role by scanning the user's group list sorted by Precedence (ascending) and choosing the first group that has a RoleArn. A user in ['team', 'enterprise'] gets the enterprise role (Precedence 1 wins over 10). This means upgrading a user is as simple as adding them to the higher-tier group — the lower-precedence group assignment can remain as a historical record or be removed.
Group-based rate limiting pattern
import { DynamoDBClient, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
const db = new DynamoDBClient({ region: 'us-east-1' });
const PLAN_LIMITS: Record<string, number> = {
enterprise: 100_000,
team: 10_000,
author: 1_000,
free: 100,
};
async function checkAndIncrementQuota(userId: string, plan: string): Promise<void> {
const monthKey = new Date().toISOString().slice(0, 7); // 'YYYY-MM'
const limit = PLAN_LIMITS[plan] ?? 100;
try {
await db.send(new UpdateItemCommand({
TableName: process.env.QUOTA_TABLE!,
Key: {
pk: { S: `USER#${userId}` },
sk: { S: `QUOTA#${monthKey}` },
},
UpdateExpression: 'SET #calls = if_not_exists(#calls, :zero) + :one',
ConditionExpression: 'attribute_not_exists(#calls) OR #calls < :limit',
ExpressionAttributeNames: { '#calls': 'apiCalls' },
ExpressionAttributeValues: {
':zero': { N: '0' },
':one': { N: '1' },
':limit': { N: String(limit) },
},
}));
} catch (err: unknown) {
if ((err as { name?: string }).name === 'ConditionalCheckFailedException') {
throw new Error(`Monthly API quota exceeded for ${plan} plan. Upgrade to increase limits.`);
}
throw err;
}
}
Store monthly quota counters in DynamoDB keyed by user ID + month. The conditional update atomically checks and increments — no separate read needed, and the condition prevents over-counting under concurrent requests. When a user upgrades their plan (group changes), the quota limit is read from the group claim on the next token, so the higher limit takes effect immediately on the next API call after token refresh.