AWS Cognito · 2026-11-03 · Cognito Advanced arc
AWS Cognito Advanced Authentication for MCP Servers: Hosted UI + PKCE, Lambda Triggers, Adaptive Security, and Group-Based RBAC
AliveMCP monitors MCP endpoints every 60 seconds — and the authentication patterns we see failing silently are the advanced ones: a Hosted UI integration where the authorization code expires before the token exchange because the state was stored in a cookie instead of the server session; a pre-token-generation trigger using V1_0 that injects claims into the ID token but not the access token, so the MCP server's bearer-token validator reads no plan claim and defaults every user to the free tier; an Advanced Security block that fires for a corporate SSO user behind a VPN on their first sign-in and cannot be unblocked without calling the feedback API; and a group-based quota check implemented with a read-then-write pattern that races under concurrent tool calls and allows users to exceed their plan limit by a factor of their concurrency. Four Cognito capabilities close the gap between a demo authentication flow and a production-grade identity layer for MCP servers. The Hosted UI with authorization code + PKCE addresses the most common requirement for MCP servers with browser-facing clients: a managed OAuth2 login page that handles sign-in, MFA challenges, and social provider federation without requiring the client to handle credentials directly; PKCE replaces the client secret for public clients (browser extensions, desktop apps, SPA frontends) by binding the authorization code to a cryptographic challenge that only the initiating client can answer, and the token endpoint exchange produces an ID token, access token, and refresh token that the MCP server validates locally against the user pool's JWKS without round-tripping to Cognito on every request. Lambda triggers provide lifecycle hooks at three points where every production MCP server needs custom logic: pre-signup for access control (blocking disposable email domains, enforcing invite-only access from a DynamoDB allowlist, auto-verifying trusted corporate domains), post-confirmation for user provisioning (creating the DynamoDB user record with default plan tier and API quota allocation, exactly once, idempotently), and pre-token-generation V2_0 for embedding custom claims (plan tier, org ID, feature flags) into every token issuance so MCP tool handlers can authorize from the bearer token without a database query per request. Advanced Security Features add risk-based authentication without any changes to MCP server code: Cognito evaluates device fingerprint, location history, IP reputation, and velocity signals for each sign-in and applies your configured response — allow, MFA challenge, or block — per risk level, while compromised credentials protection blocks sign-ins where the submitted password appears in a breach database; the event feedback API lets support teams correct false positives for users behind VPNs or corporate proxies, teaching Cognito's per-user risk model so subsequent sign-ins from the same location score lower. Groups and RBAC provide the authorization layer: groups appear as a cognito:groups claim array in every JWT, MCP tool handlers read the claim and gate access without a database query, identity pool IAM role selection uses group precedence to assign the highest-tier role when users belong to multiple groups, and atomic DynamoDB conditional updates enforce monthly API quotas per plan tier without read-then-write races. This guide synthesizes all four Cognito patterns into a complete authentication and authorization stack. Pattern 1 covers the Hosted UI + PKCE flow end to end. Pattern 2 covers Lambda trigger wiring and the three production trigger types. Pattern 3 covers Advanced Security Feature configuration and the feedback loop. Pattern 4 covers group-based RBAC and rate limiting.
TL;DR
- Hosted UI + PKCE — create a public app client with
GenerateSecret: falseandAllowedOAuthFlows: ['code']; generate the PKCE verifier withrandomBytes(32).toString('base64url')and the challenge withSHA256(verifier).digest('base64url'); store both verifier and state server-side before redirect; POST the code + verifier to the token endpoint; validate the returned ID token against the JWKS URI before trusting any claims: Domain:CreateUserPoolDomainCommand({ Domain: 'auth-myapp' })→https://auth-myapp.auth.<region>.amazoncognito.com. App client:CreateUserPoolClientCommand({ GenerateSecret: false, AllowedOAuthFlows: [OAuthFlowType.code], CallbackURLs: [...], EnableTokenRevocation: true }). Auth URL parameters:response_type=code,code_challenge=BASE64URL(SHA256(verifier)),code_challenge_method=S256,state(random hex, CSRF protection). Token endpoint:POST /oauth2/tokenwithgrant_type=authorization_code,code,redirect_uri(must match exactly),code_verifier. Refresh: same endpoint withgrant_type=refresh_token,refresh_token. JWT validation:jwtVerify(token, createRemoteJWKSet(JWKS_URI), { issuer, audience: clientId }); checktoken_use === 'id'. Custom domain: ACM cert must be inus-east-1regardless of pool region; create CNAME to the CloudFront distribution returned byDescribeUserPoolDomainCommand; propagation takes 15–30 minutes. - Lambda triggers — wire all triggers via
UpdateUserPoolCommand.LambdaConfig; usesource-arncondition on the resource-based policy to prevent confused-deputy attacks; pre-signup throws to block (message shown in Hosted UI); post-confirmation usesConditionExpression: 'attribute_not_exists(pk)'for idempotency; pre-token-generation V2_0 is required to inject claims into the access token (V1_0 only affects the ID token): Wiring:LambdaConfig: { PreSignUp: arn, PostConfirmation: arn, PreTokenGenerationConfig: { LambdaArn: arn, LambdaVersion: 'V2_0' } }. Resource policy:aws lambda add-permission --principal cognito-idp.amazonaws.com --source-arn arn:aws:cognito-idp:<region>:<account>:userpool/<id>. Pre-signup: checkBLOCKED_DOMAINSset → throw; check DynamoDB allowlist → throw if missing; check trusted domains → setevent.response.autoVerifyEmail = true; event.response.autoConfirmUser = true; return event. Post-confirmation: guard onevent.triggerSource === 'PostConfirmation_ConfirmSignUp';PutItemCommandwithConditionExpression: 'attribute_not_exists(pk)'; swallowConditionalCheckFailedException. Pre-token-generation V2_0: fetch plan + orgId from DynamoDB; setevent.response.claimsAndScopeOverrideDetails.accessTokenGeneration.claimsToAddOrOverrideandidTokenGeneration.claimsToAddOrOverride; keep total JWT under 4 KB; serialize feature flags as comma-separated string. - Advanced Security Features — start in AUDIT mode for 1–2 weeks before switching to ENFORCED; configure per-risk-level responses via
SetRiskConfigurationCommandwithMFA_IF_CONFIGUREDfor medium risk andBLOCKfor high; enableCompromisedCredentialsRiskConfigurationwithBLOCKaction; use the event feedback API to correct VPN/corporate proxy false positives so Cognito's per-user risk model learns legitimate access patterns: Enable:UpdateUserPoolCommand({ UserPoolAddOns: { AdvancedSecurityMode: 'AUDIT' } })→ baseline for 1–2 weeks → switch to'ENFORCED'. Risk config:SetRiskConfigurationCommandwithAccountTakeoverRiskConfiguration.Actions.LowRiskResponse.EventAction: 'NO_ACTION',MediumRiskResponse.EventAction: 'MFA_IF_CONFIGURED',HighRiskResponse.EventAction: 'BLOCK'. Notifications: configureNotifyConfigurationwith SES identity ARN for block/MFA/no-action emails. Compromised credentials:CompromisedCredentialsRiskConfiguration.Actions.EventAction: 'BLOCK'. CloudWatch query: filtereventType = "Authentication"andriskLevel in ["HIGH", "MEDIUM"]against/aws/cognito/userpools/<id>/<name>. Feedback:ListUserAuthEventsCommand→ find blocked event →UpdateAuthEventFeedbackCommand({ FeedbackToken, FeedbackValue: 'Valid' }); callAdminEnableUserCommandif the account was disabled. - Groups and RBAC — create plan-tier groups with ascending Precedence (enterprise=1, team=10, author=20, free=100) and RoleArn for identity pool IAM role selection; read
cognito:groupsclaim array from the validated JWT; check groups in priority order (enterprise > team > author > free); enforce monthly quotas with an atomic DynamoDBUpdateItemCommandconditional update — no separate read needed,ConditionalCheckFailedExceptionmeans quota exceeded: Create:CreateGroupCommand({ UserPoolId, GroupName: 'enterprise', Precedence: 1, RoleArn: '...EnterpriseRole' }). Assign:AdminAddUserToGroupCommand({ Username, GroupName }). Revoke immediately:AdminUserGlobalSignOutCommandto invalidate existing refresh tokens (group changes only take effect on next token issuance). JWT extraction:const groups = (payload['cognito:groups'] as string[]) ?? []; plan resolution: checkgroups.includes('enterprise')first. Quota:UpdateItemCommand({ UpdateExpression: 'SET #calls = if_not_exists(#calls, :zero) + :one', ConditionExpression: 'attribute_not_exists(#calls) OR #calls < :limit' }); catchConditionalCheckFailedException→ return quota exceeded error. IAM role mapping: identity pool selects role from the user's group with the numerically lowest Precedence that has a RoleArn.
Pattern 1: Hosted UI + PKCE — managed OAuth2 login for public MCP clients
Most MCP servers start with API key authentication: simple, server-side, no browser required. But as soon as you need to onboard users through a web interface — sign-up with email verification, social provider login, MFA enrollment — you face a choice: build a login UI from scratch and handle all the edge cases (email verification flows, MFA challenges, password reset flows, branding), or delegate to a managed provider. Cognito's Hosted UI is Cognito's fully managed login page, served from a Cognito-managed domain or your custom domain. It handles the entire sign-in, sign-up, and MFA challenge UI without any frontend code from you.
The critical choice for browser-facing MCP clients is authorization code flow with PKCE rather than the implicit flow. The implicit flow was deprecated in OAuth 2.1 because it returns tokens directly in the URL fragment — visible in browser history, server logs, and referrer headers. Authorization code + PKCE returns a short-lived code that is exchanged server-side for tokens; PKCE (Proof Key for Code Exchange) binds the code to a cryptographic challenge so that intercepting the code is useless without the verifier.
Provisioning the Hosted UI domain and public app client
Two setup steps: create the Cognito domain (which activates the Hosted UI endpoint), and create an app client configured for public clients:
import {
CognitoIdentityProviderClient,
CreateUserPoolDomainCommand,
CreateUserPoolClientCommand,
OAuthFlowType,
} from '@aws-sdk/client-cognito-identity-provider';
const cognito = new CognitoIdentityProviderClient({ region: 'us-east-1' });
// Step 1: Activate the Hosted UI with a Cognito-managed subdomain
await cognito.send(new CreateUserPoolDomainCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
Domain: 'auth-myapp', // → auth-myapp.auth.us-east-1.amazoncognito.com
}));
// Step 2: Public app client — no secret, PKCE handles proof of possession
const { UserPoolClient } = await cognito.send(new CreateUserPoolClientCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
ClientName: 'mcp-web-client',
GenerateSecret: false, // public client — no secret in browser
AllowedOAuthFlows: [OAuthFlowType.code],
AllowedOAuthFlowsUserPoolClient: true,
AllowedOAuthScopes: ['openid', 'email', 'profile'],
SupportedIdentityProviders: ['COGNITO'],
CallbackURLs: [
'https://myapp.com/auth/callback',
'http://localhost:3000/auth/callback', // remove before production
],
LogoutURLs: ['https://myapp.com/logout'],
EnableTokenRevocation: true,
PreventUserExistenceErrors: 'ENABLED',
AccessTokenValidity: 1,
IdTokenValidity: 1,
RefreshTokenValidity: 30,
TokenValidityUnits: { AccessToken: 'hours', IdToken: 'hours', RefreshToken: 'days' },
}));
GenerateSecret: false creates a public client appropriate for browser and native apps. CallbackURLs is an exact-match whitelist — Cognito rejects any redirect_uri that does not appear verbatim in this list. Remove development URLs before shipping via UpdateUserPoolClientCommand. EnableTokenRevocation: true ensures that calling RevokeTokenCommand in your logout handler invalidates the refresh token and all access tokens derived from it on the Cognito side.
Building the PKCE authorization URL
The PKCE flow starts with generating a code verifier (a random URL-safe string) and a code challenge (SHA256 of the verifier, base64url-encoded). Both must be generated fresh for each authorization request:
import { randomBytes, createHash } from 'crypto';
function generatePkce(): { verifier: string; challenge: string } {
// 43–128 character URL-safe random string
const verifier = randomBytes(32).toString('base64url').slice(0, 64);
// S256 method: BASE64URL(SHA256(verifier))
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function buildAuthorizationUrl(params: {
domain: string; region: string; clientId: string;
redirectUri: string; scopes: string[]; state: string; codeChallenge: string;
}): string {
const base = `https://${params.domain}.auth.${params.region}.amazoncognito.com/oauth2/authorize`;
const query = new URLSearchParams({
response_type: 'code',
client_id: params.clientId,
redirect_uri: params.redirectUri,
scope: params.scopes.join(' '),
state: params.state,
code_challenge: params.codeChallenge,
code_challenge_method: 'S256',
});
return `${base}?${query.toString()}`;
}
// Before redirecting: store { verifier, state } in a server-side session
const { verifier, challenge } = generatePkce();
const state = randomBytes(16).toString('hex'); // CSRF token
The state parameter prevents CSRF attacks: store it server-side before the redirect, then on the callback compare the state query parameter against the stored value. Store the verifier server-side as well — it must be presented at the token endpoint and must not be accessible to JavaScript (not in localStorage, not in a non-httpOnly cookie).
Token endpoint exchange and JWT validation
After the user authenticates, Cognito redirects to your callback URL with a code parameter. Exchange it for tokens by POSTing to the token endpoint. The authorization code is single-use and expires in 5 minutes:
async function exchangeCodeForTokens(params: {
domain: string; region: string; code: string;
codeVerifier: string; clientId: string; redirectUri: string;
}) {
const tokenEndpoint =
`https://${params.domain}.auth.${params.region}.amazoncognito.com/oauth2/token`;
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: params.clientId,
code: params.code,
redirect_uri: params.redirectUri, // must be identical to the authorization request
code_verifier: params.codeVerifier, // proves possession without a client secret
}).toString(),
});
if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`);
return response.json();
}
// Validate the returned ID token before trusting any claims
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX/.well-known/jwks.json')
);
async function validateIdToken(idToken: string, clientId: string) {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX',
audience: clientId,
});
if (payload.token_use !== 'id') throw new Error('Expected id token');
return payload;
}
Verify both iss and aud to prevent token substitution attacks where a valid token from a different user pool or app client is presented. createRemoteJWKSet from the jose library handles key caching and automatic rotation — Cognito rotates JWKS keys periodically, and hardcoding a key will break when rotation occurs. For custom domains, the ACM certificate must be in us-east-1 regardless of your user pool's region, because Cognito's Hosted UI runs on CloudFront, which requires US East 1 certificates.
Pattern 2: Lambda triggers — lifecycle hooks for access control and user provisioning
Cognito Lambda triggers execute synchronously at key points in the authentication lifecycle. Every MCP server with user accounts needs three: a pre-signup trigger for access control (who is allowed to sign up), a post-confirmation trigger for user provisioning (creating the backing record after email verification), and a pre-token-generation trigger for embedding custom claims into every token (so tool handlers can read plan tier from the bearer token).
Wiring triggers to the user pool
All triggers are configured in a single UpdateUserPoolCommand call. Each trigger Lambda needs a resource-based policy granting Cognito permission to invoke it:
import { UpdateUserPoolCommand } from '@aws-sdk/client-cognito-identity-provider';
await cognito.send(new UpdateUserPoolCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
LambdaConfig: {
PreSignUp: 'arn:aws:lambda:us-east-1:123456789012:function:cognito-pre-signup',
PostConfirmation: 'arn:aws:lambda:us-east-1:123456789012:function:cognito-post-confirmation',
PreTokenGenerationConfig: {
LambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:cognito-pre-token',
LambdaVersion: 'V2_0', // V2_0 injects into access token; V1_0 only affects ID token
},
},
}));
// Resource-based policy on each Lambda (required — Cognito cannot invoke without it)
// aws lambda add-permission \
// --function-name cognito-pre-signup \
// --statement-id cognito-invoke \
// --principal cognito-idp.amazonaws.com \
// --action lambda:InvokeFunction \
// --source-arn arn:aws:cognito-idp:us-east-1:123456789012:userpool/us-east-1_XXXXXXXXX
The source-arn condition on the resource-based policy scopes the invocation permission to your specific user pool — without it, any Cognito pool in any account could invoke your trigger Lambda (confused-deputy attack). All trigger Lambdas must respond within 5 seconds; a timeout causes Cognito to fail the authentication attempt with a generic error.
Pre-signup trigger: gating access before the user record exists
The pre-signup trigger runs synchronously before Cognito creates the user record. Throwing any error from this handler causes Cognito to reject the registration attempt and return the error message to the client — the message appears in the Hosted UI and is returned by the API, so keep it user-friendly:
import type { PreSignUpTriggerEvent } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
const db = new DynamoDBClient({ region: 'us-east-1' });
const BLOCKED_DOMAINS = new Set([
'mailinator.com', 'guerrillamail.com', 'throwaway.email',
'temp-mail.org', 'yopmail.com', '10minutemail.com',
]);
export const handler = async (event: PreSignUpTriggerEvent): Promise<PreSignUpTriggerEvent> => {
const email = event.request.userAttributes.email ?? '';
const domain = email.split('@')[1]?.toLowerCase();
if (!domain) throw new Error('Email address is required');
if (BLOCKED_DOMAINS.has(domain)) {
throw new Error(`Sign-ups from ${domain} are not allowed`);
}
// Invite-only: check DynamoDB allowlist
if (process.env.INVITE_ONLY === 'true') {
const { Item } = await db.send(new GetItemCommand({
TableName: process.env.INVITES_TABLE!,
Key: { email: { S: email.toLowerCase() } },
}));
if (!Item) throw new Error('This email has not been invited. Join the waitlist at alivemcp.com.');
}
// Auto-verify trusted corporate domains — skip email confirmation step
const TRUSTED_DOMAINS = new Set((process.env.TRUSTED_DOMAINS ?? '').split(',').filter(Boolean));
if (TRUSTED_DOMAINS.has(domain)) {
event.response.autoVerifyEmail = true;
event.response.autoConfirmUser = true;
}
return event;
};
Set autoConfirmUser: true only for domains you control — never based on user-supplied data. The disposable domain list should be extended regularly; the domains listed above cover the most common free temporary mail services, but new providers appear frequently. Consider maintaining the blocked domain set in a DynamoDB table so it can be updated without redeploying the Lambda.
Post-confirmation trigger: idempotent user provisioning
The post-confirmation trigger fires after the user confirms their email. It is the right place to create the backing DynamoDB user record. Lambda can be retried by Cognito, so the write must be idempotent:
import type { PostConfirmationTriggerEvent } from 'aws-lambda';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
const db = new DynamoDBClient({ region: 'us-east-1' });
export const handler = async (
event: PostConfirmationTriggerEvent,
): Promise<PostConfirmationTriggerEvent> => {
// Discriminate: PostConfirmation_ConfirmSignUp vs PostConfirmation_ConfirmForgotPassword
if (event.triggerSource !== 'PostConfirmation_ConfirmSignUp') {
return event; // do NOT provision on password reset
}
const userId = event.userName;
const email = event.request.userAttributes.email;
const now = new Date().toISOString();
try {
await db.send(new PutItemCommand({
TableName: process.env.USERS_TABLE!,
Item: {
pk: { S: `USER#${userId}` },
sk: { S: 'PROFILE' },
userId: { S: userId },
email: { S: email },
plan: { S: 'free' },
createdAt: { S: now },
updatedAt: { S: now },
apiCallsThisMonth: { N: '0' },
apiQuotaLimit: { N: '1000' },
},
ConditionExpression: 'attribute_not_exists(pk)',
}));
} catch (err) {
if (err instanceof ConditionalCheckFailedException) {
// User record already exists — idempotent retry, not an error
return event;
}
throw err;
}
return event;
};
Pre-token-generation V2_0: injecting plan claims into the access token
The V2_0 trigger version (required since V1_0 only modifies the ID token) lets you inject custom claims into both the ID token and the access token. MCP servers validate bearer tokens (access tokens), so this is the only way to embed plan tier into the token the server actually validates:
import type { PreTokenGenerationV2TriggerEvent } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
const db = new DynamoDBClient({ region: 'us-east-1' });
export const handler = async (
event: PreTokenGenerationV2TriggerEvent,
): Promise<PreTokenGenerationV2TriggerEvent> => {
const { Item } = await db.send(new GetItemCommand({
TableName: process.env.USERS_TABLE!,
Key: { pk: { S: `USER#${event.userName}` }, sk: { S: 'PROFILE' } },
}));
const plan = Item?.plan?.S ?? 'free';
const orgId = Item?.orgId?.S ?? '';
const features = (Item?.features?.SS ?? []).join(','); // comma-separated, keeps JWT under 4KB
event.response.claimsAndScopeOverrideDetails = {
idTokenGeneration: {
claimsToAddOrOverride: { 'custom:plan': plan, 'custom:org_id': orgId, 'custom:features': features },
claimsToSuppress: [],
groupOverrideDetails: {},
},
accessTokenGeneration: {
claimsToAddOrOverride: { 'custom:plan': plan, 'custom:org_id': orgId },
claimsToSuppress: [],
scopesToAdd: [],
scopesToSuppress: [],
},
};
return event;
};
Custom claim names must be prefixed with custom:. The total compressed JWT payload must remain under 4 KB — avoid injecting large arrays or deeply nested objects. The trigger runs on every token issuance (initial sign-in and every token refresh), so DynamoDB reads here are on the hot path; use a DAX cache or keep the read projection minimal.
Pattern 3: Advanced Security Features — risk-based adaptive authentication
Cognito Advanced Security Features (ASF) adds adaptive authentication without any changes to your MCP server's authentication code or token validation logic. For each sign-in event, Cognito evaluates device fingerprint, geographic location history, IP reputation, and sign-in velocity to assign a risk level: LOW, MEDIUM, or HIGH. Your configured response per risk level determines whether the sign-in proceeds normally, requires an MFA challenge, or is blocked outright. Compromised credentials protection is a separate signal: Cognito checks the submitted password against continuously updated breach databases independently of risk scoring.
AUDIT before ENFORCED: observing your risk distribution
Always start with AUDIT mode. ENFORCED with aggressive block rules applied to a user base you haven't characterized will block legitimate users, particularly those behind VPNs, Tor exit nodes, or corporate proxies:
import { UpdateUserPoolCommand, AdvancedSecurityModeType } from '@aws-sdk/client-cognito-identity-provider';
// Phase 1: Enable risk evaluation and logging without enforcement
await cognito.send(new UpdateUserPoolCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
UserPoolAddOns: {
AdvancedSecurityMode: AdvancedSecurityModeType.AUDIT,
},
}));
// After 1–2 weeks of AUDIT: query CloudWatch Logs Insights to see your risk distribution
// Log group: /aws/cognito/userpools/us-east-1_XXXXXXXXX/<pool-name>
/*
fields @timestamp, eventType, riskDecision, riskLevel, userId, ipAddress
| filter eventType = "Authentication"
| filter riskLevel in ["HIGH", "MEDIUM"]
| sort @timestamp desc
| limit 100
*/
// Phase 2: Switch to enforcement after baselining
await cognito.send(new UpdateUserPoolCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
UserPoolAddOns: {
AdvancedSecurityMode: AdvancedSecurityModeType.ENFORCED,
},
}));
Configuring risk responses and compromised credentials protection
import {
SetRiskConfigurationCommand,
RiskDecisionType,
CompromisedCredentialsEventActionType,
} from '@aws-sdk/client-cognito-identity-provider';
await cognito.send(new SetRiskConfigurationCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
AccountTakeoverRiskConfiguration: {
Actions: {
LowRiskResponse: { Notify: false, EventAction: RiskDecisionType.NO_ACTION },
MediumRiskResponse: { Notify: true, EventAction: RiskDecisionType.MFA_IF_CONFIGURED },
HighRiskResponse: { Notify: true, EventAction: RiskDecisionType.BLOCK },
},
NotifyConfiguration: {
From: 'security@myapp.com',
ReplyTo: 'support@myapp.com',
SourceArn: 'arn:aws:ses:us-east-1:123456789012:identity/myapp.com',
BlockEmail: {
Subject: 'Sign-in to your account was blocked',
HtmlBody: '<p>A sign-in was blocked due to suspicious activity. Contact support if this was you.</p>',
TextBody: 'A sign-in was blocked due to suspicious activity. Contact support if this was you.',
},
MfaEmail: {
Subject: 'Additional verification required',
HtmlBody: '<p>Unusual sign-in detected. Please complete MFA to continue.</p>',
TextBody: 'Unusual sign-in detected. Please complete MFA.',
},
},
},
CompromisedCredentialsRiskConfiguration: {
EventFilter: ['SIGN_IN', 'SIGN_UP', 'PASSWORD_CHANGE'],
Actions: { EventAction: CompromisedCredentialsEventActionType.BLOCK },
},
}));
MFA_IF_CONFIGURED only challenges users who have MFA set up — it does not force MFA enrollment. For higher-security environments where you want to require MFA for suspicious sign-ins, use MFA_REQUIRED (which forces MFA enrollment before allowing the sign-in). Common false-positive scenario: corporate users behind a VPN whose IP range Cognito has never seen will score HIGH risk on their first sign-in from each new IP. The standard response is to unblock via the feedback API (below) and educate the user, not to lower the HIGH-risk response to allow.
Event feedback API: correcting misclassified risk scores
The feedback API feeds signal back into Cognito's per-user risk model. When support confirms that a blocked sign-in was legitimate, calling UpdateAuthEventFeedbackCommand with FeedbackValue: 'Valid' adjusts the model for that user so subsequent sign-ins from the same device and location score lower:
import {
ListUserAuthEventsCommand,
UpdateAuthEventFeedbackCommand,
AdminEnableUserCommand,
} from '@aws-sdk/client-cognito-identity-provider';
async function unblockUser(userPoolId: string, username: string) {
// Fetch recent auth events for the user
const { AuthEvents } = await cognito.send(new ListUserAuthEventsCommand({
UserPoolId: userPoolId,
Username: username,
MaxResults: 10,
}));
// Find the most recent blocked event
const blockedEvent = AuthEvents?.find(e =>
e.EventType === 'SignIn' && e.EventRisk?.RiskDecision === 'Block'
);
if (blockedEvent?.EventId) {
await cognito.send(new UpdateAuthEventFeedbackCommand({
UserPoolId: userPoolId,
Username: username,
EventId: blockedEvent.EventId,
FeedbackToken: blockedEvent.EventFeedback?.FeedbackToken ?? '',
FeedbackValue: 'Valid', // was a legitimate sign-in, not an attack
}));
}
// The BLOCK response does not disable the Cognito account — it only rejected the session.
// If the account was disabled by admin action, re-enable it:
await cognito.send(new AdminEnableUserCommand({ UserPoolId: userPoolId, Username: username }));
}
The BLOCK action rejects the authentication session but does not disable the Cognito user account — the user remains active, and their refresh tokens remain valid if they had any from before the block. After marking an event as Valid, the user will score lower risk on the next sign-in attempt from the same device/location, but they may still need to complete MFA or sign in again to get a new session.
Pattern 4: Groups and RBAC — plan-tier authorization from JWT claims
Cognito user groups provide a lightweight RBAC layer built directly into the user pool. Groups appear as a cognito:groups string array in the ID token's JWT claims. An MCP tool handler reads this claim after validating the token and gates access accordingly — no database query per request. Groups can also map to IAM roles via Cognito Identity Pools for tools that need to call AWS APIs with user-scoped credentials.
Creating plan-tier groups and managing membership
import {
CreateGroupCommand,
AdminAddUserToGroupCommand,
AdminRemoveUserFromGroupCommand,
AdminUserGlobalSignOutCommand,
} from '@aws-sdk/client-cognito-identity-provider';
// Create plan groups — Precedence determines IAM role priority (lower = higher)
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,
RoleArn: 'arn:aws:iam::123456789012:role/CognitoEnterpriseRole',
})),
cognito.send(new CreateGroupCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
GroupName: 'team',
Precedence: 10,
RoleArn: 'arn:aws:iam::123456789012:role/CognitoTeamRole',
})),
cognito.send(new CreateGroupCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
GroupName: 'free',
Precedence: 100,
// No RoleArn — free users get the unauthenticated identity pool role
})),
]);
// Assign user after Stripe payment confirmed
await cognito.send(new AdminAddUserToGroupCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
Username: 'user-sub-uuid',
GroupName: 'team',
}));
// Downgrade: remove from paid group AND force token refresh for immediate effect
await cognito.send(new AdminRemoveUserFromGroupCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
Username: 'user-sub-uuid',
GroupName: 'team',
}));
// Without this, existing tokens remain valid until expiry with the old group claim
await cognito.send(new AdminUserGlobalSignOutCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
Username: 'user-sub-uuid',
}));
Group membership changes only take effect on the next token issuance — existing access tokens and ID tokens keep the old group claim until they expire. For downgrade enforcement (subscription cancellation, overage enforcement), call AdminUserGlobalSignOutCommand immediately to invalidate all refresh tokens, forcing the user through sign-in and picking up the updated group membership.
Group-gated 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')
);
type Plan = 'enterprise' | 'team' | 'author' | 'free';
async function extractPlan(authHeader: string | undefined): Promise<{ userId: string; plan: Plan }> {
if (!authHeader?.startsWith('Bearer ')) throw new Error('Missing Authorization header');
const { payload } = await jwtVerify(authHeader.slice(7), 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) ?? [];
// Check in priority order — a user in both 'enterprise' and 'author' gets enterprise access
let plan: 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, plan };
}
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 { userId, plan } = await extractPlan(context.requestContext?.headers?.authorization);
if (!['team', 'enterprise'].includes(plan)) {
return {
content: [{ type: 'text', text: JSON.stringify({
error: 'Private endpoints require a Team or Enterprise plan',
current_plan: plan,
upgrade_url: 'https://alivemcp.com/#pricing',
})}],
};
}
// Enforce monthly quota atomically before proceeding
await checkAndIncrementQuota(userId, plan);
return { content: [{ type: 'text', text: JSON.stringify({ status: 'ok', endpoint: url }) }] };
});
Atomic quota enforcement with DynamoDB conditional updates
Rate limiting with a read-then-write pattern races under concurrent requests — two concurrent calls can both read a count of 999, both check it against a 1000 limit, both decide to proceed, and both write 1000, allowing 2000 total calls. DynamoDB conditional updates make the check-and-increment atomic:
import { DynamoDBClient, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { ConditionalCheckFailedException } 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) {
if (err instanceof ConditionalCheckFailedException) {
throw new Error(`Monthly API quota exceeded (${plan} plan: ${limit} calls/month). Upgrade at alivemcp.com/#pricing`);
}
throw err;
}
}
The conditional update atomically checks the counter against the limit and increments it in a single DynamoDB operation — no race condition is possible. When a user upgrades their plan (group changes), the new quota limit takes effect on their next API call after token refresh, because the limit is read from the plan claim on each call rather than stored in the quota table. The quota row for the month is keyed by user ID + month string (YYYY-MM), so it auto-resets on the first call of each new month without any scheduled cleanup job.
| Group | Precedence | Monthly API calls | Private endpoints | IAM role |
|---|---|---|---|---|
| enterprise | 1 | 100,000 | Unlimited | CognitoEnterpriseRole |
| team | 10 | 10,000 | 10 | CognitoTeamRole |
| author | 20 | 1,000 | 3 | CognitoAuthorRole |
| free | 100 | 100 | 0 (public only) | unauthenticated role |
Production checklist
| Item | Pattern | Notes |
|---|---|---|
GenerateSecret: false for browser/native app clients | Hosted UI | PKCE replaces client secret for public clients |
| PKCE verifier and state stored server-side before redirect | Hosted UI | Not in localStorage, not in non-httpOnly cookie |
redirect_uri in token exchange matches authorization request exactly | Hosted UI | Cognito verifies this as a second binding check |
ACM cert for custom Hosted UI domain is in us-east-1 | Hosted UI | CloudFront requirement regardless of pool region |
All Lambda triggers have source-arn scoped resource-based policy | Lambda triggers | Prevents confused-deputy invocation from other pools |
Post-confirmation uses attribute_not_exists(pk) condition | Lambda triggers | Idempotent against Lambda retries |
Post-confirmation guards on triggerSource === 'PostConfirmation_ConfirmSignUp' | Lambda triggers | Do not re-provision on password reset |
Pre-token-generation uses LambdaVersion: 'V2_0' | Lambda triggers | V1_0 cannot inject claims into access token |
| Total JWT payload under 4 KB after custom claim injection | Lambda triggers | Serialize feature flags as comma-separated string |
ASF started in AUDIT mode; baseline for 1–2 weeks before ENFORCED | Advanced Security | Avoid blocking VPN/corporate users on day 1 |
| Event feedback API wired into support tooling for unblocking users | Advanced Security | Required for VPN/travel false positive recovery |
Group membership downgrade calls AdminUserGlobalSignOutCommand | Groups + RBAC | Without this, old group claim persists until token expiry |
| Quota uses conditional update, not read-then-write | Groups + RBAC | Eliminates race condition under concurrent tool calls |