Amazon Cognito Deep Dive: Beyond Basic Authentication
A technical guide to advanced Amazon Cognito: custom auth flows, federation, multi-tenancy, migration strategies, and production-grade security with CDK.
Amazon Cognito covers sign-up and sign-in with very little configuration. Production systems ask for more: tenant context carried inside the token, corporate SSO, a migration path off an existing provider, and a backup plan for a directory that AWS does not replicate across regions.
For an AWS-native product with standard authentication requirements, the default is a single shared user pool with custom attributes for tenant isolation, Lambda triggers for the behaviour Cognito does not model natively, and an API Gateway Cognito authorizer with a short cache TTL. Siloed pools per tenant, SAML federation, and a custom identity service each earn their complexity only under specific conditions: a tenant count in the hundreds, a B2B customer arriving with its own identity provider, or scale beyond what a managed directory serves well.
Understanding the Architecture#
User Pools vs Identity Pools#
The distinction between User Pools and Identity Pools confuses many developers initially. They serve fundamentally different purposes:
User Pools handle authentication: they validate who users are. They manage user directories, credentials, MFA, password policies, and OAuth flows. When users sign in, they receive JWT tokens (ID token, access token, refresh token).
Identity Pools handle authorization: they provide temporary AWS credentials to access services like S3, DynamoDB, or SQS directly from client applications. They exchange authentication tokens (from User Pools or external providers) for AWS credentials.
A User Pool alone covers a frontend calling API Gateway or backend services. An Identity Pool alone fits guest access to AWS resources such as analytics or public data. Reach for both together when authenticated users need to access S3 or DynamoDB directly from the frontend.
Production Setup with CDK#
Here’s a complete setup demonstrating both User Pool and Identity Pool with proper security configuration:
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as iam from 'aws-cdk-lib/aws-iam';
// User Pool for Authentication
const userPool = new cognito.UserPool(this, 'UserPool', {
selfSignUpEnabled: false, // Production: Control user creation
signInAliases: { email: true, username: true },
autoVerify: { email: true },
passwordPolicy: {
minLength: 12,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
},
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
advancedSecurityMode: cognito.AdvancedSecurityMode.ENFORCED,
mfa: cognito.Mfa.OPTIONAL,
mfaSecondFactor: {
sms: true,
otp: true, // Time-based one-time password (TOTP)
},
});
// App client for web application
const appClient = userPool.addClient('WebAppClient', {
authFlows: {
userPassword: false, // Disable less secure flow
userSrp: true, // Secure Remote Password
custom: true, // Enable custom auth flows
},
oAuth: {
flows: {
authorizationCodeGrant: true,
implicitCodeGrant: false, // Avoid implicit flow in production
},
scopes: [
cognito.OAuthScope.OPENID,
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE,
cognito.OAuthScope.custom('billing-api/read'),
],
callbackUrls: ['https://app.example.com/callback'],
logoutUrls: ['https://app.example.com/logout'],
},
generateSecret: true, // Required for server-side apps
});
// Identity Pool for AWS resource access
const identityPool = new cognito.CfnIdentityPool(this, 'IdentityPool', {
allowUnauthenticatedIdentities: false,
cognitoIdentityProviders: [{
clientId: appClient.userPoolClientId,
providerName: userPool.userPoolProviderName,
}],
});
// Authenticated role with scoped permissions
const authenticatedRole = new iam.Role(this, 'CognitoAuthenticatedRole', {
assumedBy: new iam.FederatedPrincipal(
'cognito-identity.amazonaws.com',
{
StringEquals: {
'cognito-identity.amazonaws.com:aud': identityPool.ref,
},
'ForAnyValue:StringLike': {
'cognito-identity.amazonaws.com:amr': 'authenticated',
},
},
'sts:AssumeRoleWithWebIdentity'
),
});
// Grant specific S3 access with user-scoped paths
authenticatedRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:GetObject', 's3:PutObject'],
resources: ['arn:aws:s3:::my-bucket/${cognito-identity.amazonaws.com:sub}/*'],
}));
A few of these settings carry weight. selfSignUpEnabled: false prevents unauthorized user creation, and advancedSecurityMode: ENFORCED turns on compromised credential detection. mfa: OPTIONAL keeps flexibility (avoid REQUIRED; changing it back is not supported), while generateSecret: true applies to backend clients that can securely store a secret.
Custom Authentication Flows#
CAPTCHA verification, a security question, or a fully passwordless flow all route through the same three Lambda triggers, which orchestrate the challenge sequence together.
How Custom Auth Works#
Multi-Factor Challenge Implementation#
This example implements a complete flow: password → CAPTCHA → security question.
// Define Auth Challenge - Orchestrates the challenge sequence
export const defineAuthChallenge = async (event: DefineAuthChallengeTrigger) => {
const session = event.request.session;
// First challenge: SRP password verification (handled by Cognito)
if (session.length === 0) {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = 'SRP_A';
}
// Second challenge: SRP password verifier
else if (session.length === 1 && session[0].challengeName === 'SRP_A') {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = 'PASSWORD_VERIFIER';
}
// Third challenge: CAPTCHA
else if (session.length === 2 && session[1].challengeName === 'PASSWORD_VERIFIER'
&& session[1].challengeResult === true) {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = 'CUSTOM_CHALLENGE';
event.response.challengeMetadata = 'CAPTCHA_CHALLENGE';
}
// Fourth challenge: Security question
else if (session.length === 3 && session[2].challengeName === 'CUSTOM_CHALLENGE'
&& session[2].challengeResult === true) {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = 'CUSTOM_CHALLENGE';
event.response.challengeMetadata = 'SECURITY_QUESTION';
}
// All challenges passed
else if (session.length === 4 && session[3].challengeName === 'CUSTOM_CHALLENGE'
&& session[3].challengeResult === true) {
event.response.issueTokens = true;
event.response.failAuthentication = false;
}
// Challenge failed
else {
event.response.issueTokens = false;
event.response.failAuthentication = true;
}
return event;
};
// Create Auth Challenge - Generates challenge data
export const createAuthChallenge = async (event: CreateAuthChallengeTrigger) => {
const metadata = event.request.challengeMetadata;
if (metadata === 'CAPTCHA_CHALLENGE') {
// Generate CAPTCHA using external service or internal logic
const captchaToken = await generateCaptcha();
event.response.publicChallengeParameters = {
captchaUrl: `https://captcha.example.com/${captchaToken}`,
challengeType: 'CAPTCHA',
};
event.response.privateChallengeParameters = {
captchaAnswer: await getCaptchaAnswer(captchaToken),
};
}
else if (metadata === 'SECURITY_QUESTION') {
// Fetch user's security question from DynamoDB
const question = await getSecurityQuestion(event.userName);
event.response.publicChallengeParameters = {
question: question.text,
challengeType: 'SECURITY_QUESTION',
};
event.response.privateChallengeParameters = {
answer: question.answer,
};
}
return event;
};
// Verify Auth Challenge Response
export const verifyAuthChallenge = async (event: VerifyAuthChallengeTrigger) => {
const privateParams = event.request.privateChallengeParameters;
const challengeAnswer = event.request.challengeAnswer;
if (privateParams.captchaAnswer) {
event.response.answerCorrect =
challengeAnswer.toLowerCase() === privateParams.captchaAnswer.toLowerCase();
}
else if (privateParams.answer) {
event.response.answerCorrect =
challengeAnswer.toLowerCase() === privateParams.answer.toLowerCase();
}
return event;
};
The challenge sequence has to stay deterministic based on the session array, with challengeMetadata differentiating between custom challenges. privateChallengeParameters never reaches the client; it exists only for server-side verification. Each trigger also carries a 5-second timeout, so the logic inside needs to stay fast.
Token Customization for Multi-Tenancy#
Pre Token Generation Lambda allows adding custom claims to JWT tokens, essential for multi-tenant SaaS applications where tenant context must travel with every request.
Pre Token Generation V2#
// Pre Token Generation V2 - Customize both ID and Access tokens
export const preTokenGeneration = async (event: PreTokenGenerationTriggerEvent) => {
// Fetch tenant and role information from DynamoDB
const userMetadata = await getUserMetadata(event.userName);
// V2 events carry claim and scope overrides under claimsAndScopeOverrideDetails
event.response.claimsAndScopeOverrideDetails = {
idTokenGeneration: { claimsToAddOrOverride: {} },
};
const idTokenClaims =
event.response.claimsAndScopeOverrideDetails.idTokenGeneration.claimsToAddOrOverride;
if (event.request.userAttributes['custom:tenantId']) {
const tenantId = event.request.userAttributes['custom:tenantId'];
// Verify tenant is active
const tenant = await getTenantById(tenantId);
if (!tenant || tenant.status !== 'ACTIVE') {
throw new Error('Tenant is not active');
}
// Add custom claims to ID token (for user info)
Object.assign(idTokenClaims, {
'custom:tenantId': tenantId,
'custom:tenantName': tenant.name,
'custom:organizationId': tenant.organizationId,
'custom:role': userMetadata.role,
'custom:permissions': JSON.stringify(userMetadata.permissions),
});
// Customize Access Token (Cognito Essentials/Plus tier only)
if (event.triggerSource === 'TokenGeneration_Authentication') {
event.response.claimsAndScopeOverrideDetails.accessTokenGeneration = {
claimsToAddOrOverride: {
'tenant_id': tenantId,
'role': userMetadata.role,
},
claimsToSuppress: [],
scopesToAdd: [`tenant:${tenantId}:read`, `tenant:${tenantId}:write`],
};
}
}
// Add subscription tier for feature flags
if (userMetadata.subscriptionTier) {
idTokenClaims['custom:tier'] = userMetadata.subscriptionTier;
}
return event;
};
// DynamoDB helper functions
async function getUserMetadata(username: string) {
const result = await dynamoDB.get({
TableName: 'UserMetadata',
Key: { username },
}).promise();
return result.Item || { role: 'user', permissions: [] };
}
async function getTenantById(tenantId: string) {
const result = await dynamoDB.get({
TableName: 'Tenants',
Key: { tenantId },
}).promise();
return result.Item;
}
Sensitive data such as passwords or API keys should never end up in a token, and token size needs to stay under 8KB to avoid hitting HTTP header limits. Large permission sets are better represented as opaque references than embedded directly, and tenant context needs validation to prevent token forgery.
Warning
Token Size Pitfall: Adding too many custom claims can push tokens over 8KB, causing HTTP 431 errors. Monitor token size in production and use reference IDs instead of embedding large data structures.
Multi-Tenancy Patterns#
Tenant count decides most of it.
Shared Pool with Custom Attributes#
This pattern works well for most SaaS applications with fewer than 100 tenants:
// Shared User Pool with tenant isolation
const userPool = new cognito.UserPool(this, 'MultiTenantUserPool', {
selfSignUpEnabled: false,
standardAttributes: {
email: { required: true, mutable: true },
},
customAttributes: {
tenantId: new cognito.StringAttribute({
minLen: 1,
maxLen: 128,
mutable: false, // Cannot change tenant after creation
}),
organizationId: new cognito.StringAttribute({
minLen: 1,
maxLen: 128,
mutable: false,
}),
role: new cognito.StringAttribute({
minLen: 1,
maxLen: 64,
mutable: true, // Role can be updated
}),
},
});
// Pre Sign Up - Assign tenant from invitation token
export const preSignUp = async (event: PreSignUpTriggerEvent) => {
const invitationToken = event.request.validationData?.invitationToken;
if (!invitationToken) {
throw new Error('Invitation token required');
}
// Validate invitation and get tenant info
const invitation = await validateInvitation(invitationToken);
if (!invitation || invitation.expired) {
throw new Error('Invalid or expired invitation');
}
// Auto-confirm and set tenant attributes
event.response.autoConfirmUser = true;
event.response.autoVerifyEmail = true;
// These will be set as custom attributes
event.request.userAttributes['custom:tenantId'] = invitation.tenantId;
event.request.userAttributes['custom:organizationId'] = invitation.organizationId;
event.request.userAttributes['custom:role'] = invitation.role;
// Mark invitation as used
await markInvitationUsed(invitationToken, event.userName);
return event;
};
SAML Federation with Enterprise Identity Providers#
Federation lets users authenticate through corporate identity providers such as Azure AD, Okta, or OneLogin.
Azure AD SAML Configuration#
// CDK setup for SAML provider
const samlProvider = new cognito.UserPoolIdentityProviderSaml(this, 'AzureADProvider', {
userPool,
name: 'AzureAD',
metadata: cognito.UserPoolIdentityProviderSamlMetadata.url(
'https://login.microsoftonline.com/TENANT_ID/federationmetadata/2007-06/federationmetadata.xml'
),
attributeMapping: {
email: cognito.ProviderAttribute.other('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'),
givenName: cognito.ProviderAttribute.other('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'),
familyName: cognito.ProviderAttribute.other('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'),
custom: {
'tenantId': cognito.ProviderAttribute.other('http://schemas.microsoft.com/identity/claims/tenantid'),
},
},
idpSignout: true,
});
// Link federated user to existing profile (avoiding duplicates)
export const postAuthentication = async (event: PostAuthenticationTriggerEvent) => {
// Check if this is a federated identity
if (event.request.userAttributes.identities) {
const identities = JSON.parse(event.request.userAttributes.identities);
const federatedIdentity = identities[0];
if (federatedIdentity.providerName === 'AzureAD') {
const email = event.request.userAttributes.email;
// Check if user already exists with this email
const existingUser = await findUserByEmail(email);
if (existingUser && existingUser.username !== event.userName) {
// Link the federated identity to existing user
await idp.adminLinkProviderForUser({
UserPoolId: event.userPoolId,
DestinationUser: {
ProviderName: 'Cognito',
ProviderAttributeValue: existingUser.username,
},
SourceUser: {
ProviderName: federatedIdentity.providerName,
ProviderAttributeName: 'Cognito_Subject',
ProviderAttributeValue: federatedIdentity.userId,
},
}).promise();
// Log the linking for audit
await auditLog({
action: 'FEDERATED_IDENTITY_LINKED',
email,
provider: federatedIdentity.providerName,
});
}
}
}
return event;
};
A handful of practices keep this reliable.
- Use the metadata URL for automatic certificate rotation
- Map NameId to an immutable attribute (user_id, not email)
- Implement account linking to prevent duplicate users
- Test both SP-initiated and IdP-initiated logout flows
Tip
Federation Testing: Test logout flows thoroughly. Federated logout requires coordination between Cognito, IdP, and application. Users appearing logged out in the app but still authenticated at IdP level is a common issue.
API Gateway Integration#
Complete Integration Setup#
// CDK: API Gateway with Cognito authorizer
const api = new apigateway.RestApi(this, 'MyApi', {
restApiName: 'Secure API',
deployOptions: {
stageName: 'prod',
tracingEnabled: true,
},
});
const authorizer = new apigateway.CognitoUserPoolsAuthorizer(this, 'CognitoAuthorizer', {
cognitoUserPools: [userPool],
authorizerName: 'CognitoAuthorizer',
identitySource: 'method.request.header.Authorization',
resultsCacheTtl: Duration.minutes(5), // Cache authorization decisions
});
// Protected endpoint requiring specific OAuth scope
const protectedResource = api.root.addResource('billing');
protectedResource.addMethod('GET', new apigateway.LambdaIntegration(billingFunction), {
authorizer,
authorizationType: apigateway.AuthorizationType.COGNITO,
authorizationScopes: ['billing-api/read'], // OAuth scope validation
requestValidator: new apigateway.RequestValidator(this, 'RequestValidator', {
restApi: api,
validateRequestBody: true,
validateRequestParameters: true,
}),
});
// Lambda function with JWT validation and tenant isolation
export const handler = async (event: APIGatewayProxyEvent) => {
// API Gateway already validated JWT, extract claims
const claims = event.requestContext.authorizer?.claims;
if (!claims) {
return { statusCode: 401, body: 'Unauthorized' };
}
const tenantId = claims['custom:tenantId'];
const role = claims['custom:role'];
// Verify tenant context
if (!tenantId) {
return { statusCode: 403, body: 'Missing tenant context' };
}
// Query with tenant isolation
const result = await dynamoDB.query({
TableName: 'BillingRecords',
IndexName: 'TenantIndex',
KeyConditionExpression: 'tenantId = :tenantId',
ExpressionAttributeValues: {
':tenantId': tenantId,
},
}).promise();
// Apply role-based filtering
const filteredRecords = filterByRole(result.Items, role);
return {
statusCode: 200,
body: JSON.stringify(filteredRecords),
};
};
Cache TTL is a straightforward trade-off between performance and security.
| Cache TTL | Performance | Security | Use Case |
|---|---|---|---|
| None | Highest latency | Real-time permissions | High-security operations |
| 5 min | Good balance | ~5 min lag | Standard API endpoints |
| 30-60 min | Best performance | Stale permissions | Read-only public data |
Cached decisions persist for the full TTL even when permissions change. For critical permission changes, use shorter TTL or implement cache-busting strategies.
Migration from External Auth Providers#
Lazy Migration Strategy#
// User Migration Lambda - Lazy migration approach
export const userMigration = async (event: UserMigrationTriggerEvent) => {
if (event.triggerSource === 'UserMigration_Authentication') {
// User tries to sign in but doesn't exist in Cognito
const { userName, password } = event.request;
try {
// Validate credentials against Auth0
const auth0User = await validateWithAuth0(userName, password);
if (auth0User) {
// User is valid, migrate to Cognito
event.response.userAttributes = {
email: auth0User.email,
email_verified: 'true',
given_name: auth0User.given_name,
family_name: auth0User.family_name,
'custom:auth0Id': auth0User.user_id,
'custom:migratedAt': new Date().toISOString(),
};
event.response.finalUserStatus = 'CONFIRMED';
event.response.messageAction = 'SUPPRESS'; // Don't send welcome email
// Log migration for tracking
await logMigration(userName, 'success');
return event;
}
} catch (error) {
await logMigration(userName, 'failed', error);
throw error;
}
}
if (event.triggerSource === 'UserMigration_ForgotPassword') {
// User requests password reset but doesn't exist in Cognito
const { userName } = event.request;
// Check if user exists in Auth0
const auth0User = await getUserFromAuth0(userName);
if (auth0User) {
event.response.userAttributes = {
email: auth0User.email,
email_verified: 'true',
'custom:auth0Id': auth0User.user_id,
};
event.response.messageAction = 'SUPPRESS';
return event;
}
}
throw new Error('User not found in legacy system');
};
async function validateWithAuth0(username: string, password: string) {
const response = await axios.post('https://YOUR_DOMAIN.auth0.com/oauth/token', {
grant_type: 'password',
username,
password,
client_id: process.env.AUTH0_CLIENT_ID,
client_secret: process.env.AUTH0_CLIENT_SECRET,
audience: process.env.AUTH0_AUDIENCE,
scope: 'openid profile email',
});
if (response.data.access_token) {
// Get user info
const userInfo = await axios.get('https://YOUR_DOMAIN.auth0.com/userinfo', {
headers: { Authorization: `Bearer ${response.data.access_token}` },
});
return userInfo.data;
}
return null;
}
The User Migration Lambda gets implemented and tested against staging users first. Once lazy migration is enabled in production, active users migrate on their own schedule while the count of migrated accounts gets tracked. The remaining inactive accounts get a bulk import via CSV or the admin API once the active population has migrated, and the legacy system is decommissioned only after confirming every user has moved over.
How long the lazy phase runs depends on how often users sign in; a tail of dormant accounts never authenticates at all, and the bulk import step covers them.
Advanced Security Features#
Cognito’s advanced security requires Plus tier pricing but provides enterprise-grade protection.
Security Configuration#
// Enable Advanced Security (Plus tier required)
const userPool = new cognito.UserPool(this, 'SecureUserPool', {
advancedSecurityMode: cognito.AdvancedSecurityMode.ENFORCED,
signInAliases: { email: true },
signInCaseSensitive: false,
});
// Post Authentication - Handle risk levels
export const postAuthentication = async (event: PostAuthenticationTriggerEvent) => {
const riskLevel = event.request.userContextData?.encodedData
? parseRiskData(event.request.userContextData.encodedData)
: 'LOW';
// Log authentication with risk level
await logAuthentication({
username: event.userName,
riskLevel,
ipAddress: event.request.userContextData?.ipAddress,
deviceKey: event.request.userContextData?.deviceKey,
timestamp: new Date().toISOString(),
});
// For high-risk authentications, trigger additional security
if (riskLevel === 'HIGH' || riskLevel === 'MEDIUM') {
await sendSecurityAlert(event.userName, riskLevel);
if (riskLevel === 'HIGH') {
await setUserMFARequired(event.userPoolId, event.userName);
}
}
return event;
};
This splits into three layers.
- Compromised Credentials Protection: AWS monitors breached credential databases and blocks sign-ins with known compromised passwords
- Adaptive Authentication: Risk scores based on IP, device, location with automatic responses per risk level
- MFA Options: SMS (highest friction), TOTP (balanced), WebAuthn/FIDO2 (lowest friction)
Warning
MFA Configuration Lock-in: Once MFA is set to “REQUIRED” (for any method: SMS, TOTP, or WebAuthn), you cannot disable or change it to “OPTIONAL” without recreating the pool. Always use “OPTIONAL” and enforce MFA selectively via application logic or adaptive authentication.
SDK Comparison: Amplify vs AWS SDK#
Bundle size, feature support, and maintenance burden vary across the three.
| Criteria | AWS Amplify | amazon-cognito-identity-js | AWS SDK v3 |
|---|---|---|---|
| Bundle Size | ~500KB (tree-shakeable) | ~100KB | ~50KB (modular) |
| Use Case | Frontend apps (React, React Native) | Frontend with custom UI | Backend/server-side |
| Secret Support | No | No | Yes |
| SRP Auth | Yes, Built-in | Yes, Built-in | No, Manual implementation |
| Token Management | Yes, Automatic | Yes, Manual | No, Manual |
| OAuth Flows | Yes, Full support | Limited | Yes, Full support |
| SSR Support | Limited (Next.js/Nuxt) | No | Yes |
| Maintenance | Yes, Active | Limited, Deprecating | Yes, Active |
Amplify Frontend Implementation#
import { Amplify } from 'aws-amplify';
import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth';
Amplify.configure({
Auth: {
Cognito: {
userPoolId: 'us-east-1_ABC123',
userPoolClientId: 'abc123def456',
identityPoolId: 'us-east-1:abc123-def456',
loginWith: {
oauth: {
domain: 'auth.example.com',
scopes: ['openid', 'email', 'profile', 'billing-api/read'],
redirectSignIn: ['https://app.example.com/callback'],
redirectSignOut: ['https://app.example.com/logout'],
responseType: 'code',
},
},
},
},
});
async function handleSignIn(email: string, password: string) {
try {
const { isSignedIn, nextStep } = await signIn({
username: email,
password,
});
if (nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_TOTP_CODE') {
const code = await promptForMFACode();
await confirmSignIn({ challengeResponse: code });
}
// Tokens are automatically stored and refreshed
const user = await getCurrentUser();
return user;
} catch (error) {
console.error('Sign in error:', error);
throw error;
}
}
AWS SDK Backend Implementation#
import {
CognitoIdentityProviderClient,
AdminInitiateAuthCommand,
} from '@aws-sdk/client-cognito-identity-provider';
import { createHmac } from 'crypto';
const client = new CognitoIdentityProviderClient({ region: 'us-east-1' });
function calculateSecretHash(username: string): string {
const message = username + process.env.COGNITO_CLIENT_ID;
const hash = createHmac('sha256', process.env.COGNITO_CLIENT_SECRET!)
.update(message)
.digest('base64');
return hash;
}
async function authenticateUser(username: string, password: string) {
const command = new AdminInitiateAuthCommand({
UserPoolId: process.env.USER_POOL_ID,
ClientId: process.env.COGNITO_CLIENT_ID,
AuthFlow: 'ADMIN_USER_PASSWORD_AUTH',
AuthParameters: {
USERNAME: username,
PASSWORD: password,
SECRET_HASH: calculateSecretHash(username),
},
});
const response = await client.send(command);
return {
accessToken: response.AuthenticationResult?.AccessToken,
idToken: response.AuthenticationResult?.IdToken,
refreshToken: response.AuthenticationResult?.RefreshToken,
expiresIn: response.AuthenticationResult?.ExpiresIn,
};
}
Selection guideline: Use Amplify for React/React Native frontend applications with automatic token management. Use AWS SDK for backend services requiring client secrets and custom authentication flows.
Production Patterns and Monitoring#
Token Refresh Strategy#
const TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000; // 5 minutes
async function getValidToken(): Promise<string> {
const session = await Auth.currentSession();
const expiresAt = session.getAccessToken().getExpiration() * 1000;
if (Date.now() + TOKEN_REFRESH_THRESHOLD > expiresAt) {
const newSession = await Auth.currentSession();
return newSession.getAccessToken().getJwtToken();
}
return session.getAccessToken().getJwtToken();
}
Essential CloudWatch Metrics#
Watch SignInSuccesses and SignInThrottles for general authentication health, TokenRefreshSuccesses for refresh failures, and a couple of custom metrics: time to authenticate and MFA completion rate. Alarms belong on high failure rates, throttling, and advanced security blocks.
On the security side, compromised credential detections, high-risk authentication attempts, adaptive authentication triggers, and the account takeover prevention rate are worth tracking as well.
Failure Modes in Production#
| Failure mode | Why it happens | Mitigation |
|---|---|---|
| No backup strategy | Cognito User Pools cannot be backed up or replicated across regions; an accidental deletion or a region failure means total user data loss | Export user data daily to S3 with the ListUsers API, mirror critical metadata in DynamoDB, automate the export with a scheduled Lambda, and document the pool recreation procedure |
| Token size limits | Too many custom claims push tokens past the 8KB header limit and produce HTTP 431 errors | Store large datasets in DynamoDB and reference them by ID (permissionSetId: "ps-123") instead of embedding full objects; paginate large permission sets |
| Authorizer cache invalidation | API Gateway caches authorization decisions, so a revoked permission keeps working until the cache expires | Use a shorter TTL (5-15 minutes) for sensitive operations, or switch to a Lambda authorizer where permission checks need to be real-time |
| SMS region limitations | AWS End User Messaging SMS (formerly SNS) isn’t supported in every Cognito region | Check regional support before relying on SMS, and fall back to email verification where SMS isn’t available |
| Lambda trigger timeouts | Sync triggers have a 5-second timeout, so a slow external API call fails authentication | Keep trigger logic under 3 seconds, push non-critical work to async operations, and cache external API responses |
Cost Analysis#
Pricing Tiers (December 2024)#
Lite tier (10,000 MAUs free, then tiered pricing):
- Basic authentication, MFA, social providers
- No advanced security
- Tiered pricing after free tier: $0.0025/MAU (10K-50K), $0.00375/MAU (50K-100K), etc.
Essentials tier ($0.015/MAU):
- Advanced security (audit mode)
- Access token customization
Plus tier ($0.02/MAU):
- Advanced security (enforced mode)
- SAML/OIDC federation
- 1.33x cost vs Essentials
A few costs are easy to miss.
- SMS MFA: $0.00645/message in US (via AWS End User Messaging SMS, formerly SNS)
- Lambda trigger invocations: $0.20 per 1M requests
- API Gateway authorizer calls (if caching disabled)
On the optimization side, archiving inactive users automatically and using federation to reduce the direct user count both help, alongside keeping an eye on MAU growth trends and considering a Lambda authorizer for lower-traffic APIs.
When to Choose Cognito vs Alternatives#
Where Cognito Fits#
- AWS-native architecture
- Standard authentication requirements
- Budget-conscious projects
- Rapid MVP development
- Small to medium scale (< 10M users)
Where Alternatives Fit Better#
Auth0: Complex authentication flows, extensive customization, enterprise SLA requirements, global compliance needs
Okta: Workforce identity (employees), enterprise SSO, advanced lifecycle management
Custom Solution: Unique authentication requirements, full data control, existing identity infrastructure, very high scale (> 100M users)
Limitations to Accept#
- Limited user management APIs
- 3KB CSS customization limit
- No direct database access
Override the shared-pool default when a customer arrives with its own identity provider and contractual isolation requirements, when tenant count passes a few hundred, or when the missing cross-region replication conflicts with a recovery objective already committed to. Build the daily user export before the first production sign-up either way.
References#
- What is Amazon Cognito? (opens in new tab) - Official overview of Cognito user pools and identity pools
- Amazon Cognito User Pools (opens in new tab) - User directory for web and mobile app authentication with OIDC support
- Amazon Cognito Identity Pools (opens in new tab) - Federated identity exchange for temporary AWS credentials
- Customizing User Pool Workflows with Lambda Triggers (opens in new tab) - Pre/post authentication hooks and custom challenge flows
- Custom Authentication Challenge Lambda Triggers (opens in new tab) - Define, create, and verify challenge triggers for MFA workflows
- Configuring Identity Providers for Your User Pool (opens in new tab) - SAML and OIDC federation with enterprise identity providers
- Accessing AWS Services Using an Identity Pool After Sign-In (opens in new tab) - Integrating user pools with identity pools for AWS resource access
Related posts
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.
aws · aws-cdk · lambda +4
A private REST API structurally cannot carry gRPC, and every AWS surface that speaks gRPC excludes Lambda targets. What to keep from gRPC, and what to drop.
aws · aws-cdk · lambda +4
The private REST API, the resource policy that switches it on, per-route AWS_IAM grants, the two CDK stacks, and signing the call from a Node 22 Lambda.
aws · aws-cdk · lambda +4
SigV4 proves which service is calling and nothing about which user it is for. How to propagate a verified subject, and what the transport actually encrypts.
aws · aws-cdk · lambda +4
Same-account, the resource policy and the caller's identity policy are an OR. Cross-account they become an AND, and silence denies. What that changes in the perimeter.
aws · aws-cdk · lambda +4