Skip to content

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.

Ayhan Sipahi Ayhan Sipahi

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.

AWS Services

Application Layer

Authentication Layer

JWT Tokens

JWT Token

Temporary AWS Credentials

JWT Token

AWS Credentials

AWS Credentials

AWS Credentials

User

Cognito User Pool Authentication

Cognito Identity Pool Authorization

Web Application

API Gateway Cognito Authorizer

Lambda Functions

S3 Bucket

DynamoDB

SQS Queue

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#

Yes

No

Yes

No

User Initiates Auth

Define Auth Challenge Lambda

Create Auth Challenge Lambda

User Responds

Verify Auth Challenge Response Lambda

Issue Tokens?

Authentication Success

More Challenges?

Authentication Failed

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.

< 100 Small Scale

100-1000 Medium Scale

> 1000 Enterprise

Custom Config Per Tenant

Compliance Isolation

Standard Config

Choose Multi-Tenancy Pattern

Number of Tenants

Shared User Pool Custom Attributes

Shared Pool Groups-Based

Enterprise Requirements?

Siloed User Pools One per Tenant

Multi-Region Shared Pools

Simple setup Low operational cost Single config Limited isolation

Better isolation Group-based policies Scalable to 1000s 10,000 groups limit

Complete isolation Custom config per tenant Compliance ready High operational cost Complex automation

Geographic distribution Quota isolation Compliance by region Complex orchestration

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 TTLPerformanceSecurityUse Case
NoneHighest latencyReal-time permissionsHigh-security operations
5 minGood balance~5 min lagStandard API endpoints
30-60 minBest performanceStale permissionsRead-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.

  1. Compromised Credentials Protection: AWS monitors breached credential databases and blocks sign-ins with known compromised passwords
  2. Adaptive Authentication: Risk scores based on IP, device, location with automatic responses per risk level
  3. 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.

CriteriaAWS Amplifyamazon-cognito-identity-jsAWS SDK v3
Bundle Size~500KB (tree-shakeable)~100KB~50KB (modular)
Use CaseFrontend apps (React, React Native)Frontend with custom UIBackend/server-side
Secret SupportNoNoYes
SRP AuthYes, Built-inYes, Built-inNo, Manual implementation
Token ManagementYes, AutomaticYes, ManualNo, Manual
OAuth FlowsYes, Full supportLimitedYes, Full support
SSR SupportLimited (Next.js/Nuxt)NoYes
MaintenanceYes, ActiveLimited, DeprecatingYes, 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 modeWhy it happensMitigation
No backup strategyCognito User Pools cannot be backed up or replicated across regions; an accidental deletion or a region failure means total user data lossExport 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 limitsToo many custom claims push tokens past the 8KB header limit and produce HTTP 431 errorsStore large datasets in DynamoDB and reference them by ID (permissionSetId: "ps-123") instead of embedding full objects; paginate large permission sets
Authorizer cache invalidationAPI Gateway caches authorization decisions, so a revoked permission keeps working until the cache expiresUse 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 limitationsAWS End User Messaging SMS (formerly SNS) isn’t supported in every Cognito regionCheck regional support before relying on SMS, and fall back to email verification where SMS isn’t available
Lambda trigger timeoutsSync triggers have a 5-second timeout, so a slow external API call fails authenticationKeep 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#

Related posts