Amazon Cognito quietly split into three pricing tiers in 2025, and by September 2026 most teams building a new app still don’t know which one they’re on. The default changed too: new user pools now launch on the Essentials tier at $0.015 per monthly active user, not the older Lite-style pricing many tutorials still describe. That single change affects whether passkeys, email MFA, and Managed Login even work on your pool, and it changes your bill at scale by roughly 3x compared to the cheaper Lite option.
This guide builds a complete authentication layer for a small serverless task-tracker app: a Cognito user pool on the Essentials tier, Managed Login with passkey support, a Google social sign-in option, an API Gateway and Lambda backend locked down with a JWT authorizer, and an identity pool that hands out temporary AWS credentials for direct-to-S3 file uploads. Thirteen steps, a Terraform module you can reuse, and a troubleshooting list built from the errors that actually show up when this stack goes into production.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What Amazon Cognito Actually Does
Amazon Cognito is AWS’s managed identity service, split into two distinct products that get bundled under one name. User pools are a hosted user directory: sign-up, sign-in, password resets, MFA, social login through Google or Apple, and SAML or OIDC federation for enterprise customers. Identity pools are a separate mechanism that exchanges a verified identity (from a Cognito user pool, or from an external provider) for short-lived AWS credentials through AWS Security Token Service, so a mobile app can, for example, upload a file straight to S3 without a backend server brokering the request.
The 2026 pricing overhaul, according to AWS’s own Cognito pricing page, applies specifically to user pools. Every user pool now runs on one of three feature plans: Lite, Essentials, or Plus. AWS’s feature plan documentation ties each tier to a specific feature set: Lite covers direct sign-in and social login only, Essentials adds passkeys, email MFA, and Managed Login, and Plus layers on advanced threat protection and multi-region replication support. Pick the wrong tier and the console will simply refuse to let you turn on the feature you need, which is the single most common source of confusion in Cognito setups this year.
None of this is optional context. The tier you choose in Step 2 determines which of the later steps in this guide will even work, so treat the pricing section below as required reading rather than a reference table to skim past.
The real-world pattern is consistent across teams that adopt Cognito: a B2C mobile app uses a user pool for sign-in and an identity pool for direct S3 uploads, a B2B SaaS product federates through SAML so enterprise customers can use their own Okta or Entra ID tenant, and an internal admin tool skips social login entirely and relies on email plus mandatory MFA. All three sit on the same underlying service, and the only thing that changes between them is which features get switched on and which tier pays for those features. Recognizing which of these three shapes your app fits before you start avoids rebuilding the pool from scratch halfway through a project.
Prerequisites and Tool Versions
You’ll need an AWS account with billing enabled (Cognito’s free tier covers the first 10,000 monthly active users, so testing this guide costs nothing), plus a small set of tools on your machine. Here’s what this walkthrough assumes:
- AWS CLI v2 (2.15 or later) configured with an IAM user or role that has Cognito, IAM, Lambda, and API Gateway permissions
- Node.js 20.x or later, for the Lambda functions and the JWT verification library
- Terraform 1.9 or later, for the infrastructure-as-code step
- A domain prefix in mind for the Cognito hosted UI (something like
tasktracker-app, which becomes part of your login URL) - A Google Cloud project with OAuth credentials, if you want to follow the social login step (optional, but the guide covers it)
Budget about 90 minutes for the full walkthrough if you’re typing every command, or 40 minutes if you copy the Terraform module in Step 12 and skip the manual console steps. Either path produces the same working stack.
None of the resources created in this guide carry a cost by themselves outside of the MAU pricing covered below. API Gateway HTTP APIs, Lambda, and a Cognito user pool inside the free tier will not generate a bill for a project used only by you and a handful of test accounts, which makes this a safe stack to build and tear down repeatedly while you’re still learning it.
Cognito Pricing in 2026: Lite vs Essentials vs Plus
Cognito’s free tier gives every account 10,000 monthly active users per month, per account or AWS organization, for users who sign in directly or through a social identity provider. SAML or OIDC federation gets a much smaller free allowance of just 50 MAU, then bills at $0.015 per MAU regardless of which tier you’re on. Past the free tier, the three plans diverge sharply.
| Tier | Price per MAU (above free tier) | Free MAU/month | Key features unlocked |
|---|---|---|---|
| Lite | $0.0055 (first 90,000 billed), then $0.0046 for all additional MAUs | 10,000 | Sign-up, sign-in, social login only |
| Essentials (default) | Flat $0.015 | 10,000 | Passkeys, email MFA, Managed Login, custom auth flows |
| Plus | Flat $0.020, no free tier | 0 | Advanced threat protection, multi-region replication add-on |
The gap compounds fast at scale. A pool with 950,000 monthly active users costs roughly $4,405 a month on Lite versus $14,100 a month on Essentials, a difference of more than 3x for the same user count. Multi-region replication, when you need it, is a separate add-on priced at $0.0045 per MAU on top of whichever base tier you’re running. If you don’t need passkeys or Managed Login and your app only does basic email or social sign-in, staying on Lite is a legitimate cost decision, not a compromise. This guide uses Essentials throughout because passkeys and Managed Login are the point of Step 3 and Step 4.
User Pools vs Identity Pools: Which One You Actually Need
Teams new to Cognito frequently create an identity pool first, assuming it’s the “main” Cognito resource, then get stuck wiring up sign-in forms that identity pools were never built to handle. The two services solve different problems and most apps need both, in sequence.
A user pool is where sign-up and sign-in actually happen. It issues three JSON Web Tokens on successful authentication: an ID token describing the user, an access token scoped for calling your own APIs, and a refresh token for getting new ones without forcing a re-login. An identity pool doesn’t authenticate anyone. It takes a token from something that already did the authenticating, whether that’s a Cognito user pool, Google, Facebook, or your own custom OIDC provider, and exchanges it for temporary AWS credentials through STS. Those credentials are scoped by an IAM role you define, which is how a mobile app gets permission to write to a specific S3 prefix without embedding a long-lived AWS access key in the app binary.
For the task-tracker app in this guide, the user pool from Step 2 handles login and issues the tokens that protect the API in Step 9. The identity pool added in Step 10 is optional and only needed because the app lets users upload attachment files directly to S3 from the browser, bypassing the Lambda backend for large file transfers.
Step 1: Set Up the AWS CLI and a Least-Privilege IAM User
Skip the root account and skip an admin-everywhere IAM user too. Create a dedicated IAM user scoped to the services this project touches, since a leaked Cognito-admin credential is a much smaller incident than a leaked account-admin one.
aws iam create-user --user-name cognito-tutorial-deployer
aws iam attach-user-policy \
--user-name cognito-tutorial-deployer \
--policy-arn arn:aws:iam::aws:policy/AmazonCognitoPowerUser
aws iam attach-user-policy \
--user-name cognito-tutorial-deployer \
--policy-arn arn:aws:iam::aws:policy/AWSLambda_FullAccess
aws iam create-access-key --user-name cognito-tutorial-deployer
aws configure --profile cognito-tutorial
# paste the access key ID and secret when prompted
# set default region to us-east-1 (or your preferred region)
Verify the profile works before moving on:
aws sts get-caller-identity --profile cognito-tutorial
You should get back your account ID, user ARN, and user ID in JSON. If this command fails, stop here and fix the credentials before touching Cognito. Every step from here on assumes the cognito-tutorial profile is active.
Step 2: Create a Cognito User Pool on the Essentials Tier
This is the step that determines your pricing tier and unlocks (or blocks) everything downstream. The --user-pool-tier flag on create-user-pool accepts LITE, ESSENTIALS, or PLUS, and defaults to Essentials if you omit it, but set it explicitly so the choice is visible in your deployment scripts.
aws cognito-idp create-user-pool \
--pool-name "tasktracker-users" \
--user-pool-tier ESSENTIALS \
--auto-verified-attributes email \
--username-attributes email \
--account-recovery-setting '{
"RecoveryMechanisms": [
{"Priority": 1, "Name": "verified_email"}
]
}' \
--profile cognito-tutorial
The response includes a UserPool object with an Id field shaped like us-east-1_AbCdEfGhI. Save it as an environment variable, since every later command needs it:
export USER_POOL_ID="us-east-1_AbCdEfGhI"
Double-check the tier landed correctly, since a typo in the flag silently falls back to the default rather than erroring:
aws cognito-idp describe-user-pool \
--user-pool-id $USER_POOL_ID \
--profile cognito-tutorial \
--query "UserPool.UserPoolTier"
Output should read "ESSENTIALS". If it comes back as "LITE", the passkey and Managed Login steps below won’t work until you either recreate the pool or contact AWS Support about a tier upgrade path for existing pools, since not every account can upgrade an existing pool’s tier in place.
Step 3: Configure Managed Login and the Hosted UI Domain
Managed Login is AWS’s rebuilt hosted authentication page, available on Essentials and Plus tiers, and it replaces the older, more limited hosted UI. You still need a domain prefix for it, either a Cognito-managed subdomain or your own custom domain with a matching ACM certificate.
aws cognito-idp create-user-pool-domain \
--domain "tasktracker-app" \
--user-pool-id $USER_POOL_ID \
--managed-login-version 2 \
--profile cognito-tutorial
The managed-login-version 2 flag opts into the newer Managed Login experience instead of the legacy hosted UI. Your login page is now live at a URL shaped like https://tasktracker-app.auth.us-east-1.amazoncognito.com. From the AWS Console, under your user pool’s branding settings, you can customize the logo, colors, and text on this page without writing any front-end code, which is the main practical benefit over the old hosted UI: a real design surface instead of a single CSS override file.
Domain names are permanent once claimed and can’t be reused if you delete the pool later, so pick something you’d be comfortable keeping if this app grows past a prototype.
Step 4: Turn On Passkeys and Email MFA
Passkeys (WebAuthn-based, phishing-resistant credentials) are one of the headline features gated behind the Essentials tier. Enabling them is a user pool policy update, not a separate resource:
aws cognito-idp set-user-pool-mfa-config \
--user-pool-id $USER_POOL_ID \
--mfa-configuration OPTIONAL \
--email-mfa-configuration '{"Message": "Your task tracker code is {####}"}' \
--profile cognito-tutorial
aws cognito-idp update-user-pool \
--user-pool-id $USER_POOL_ID \
--webauthn-configuration '{
"RelyingPartyId": "tasktracker-app.com",
"UserVerification": "preferred"
}' \
--profile cognito-tutorial
Setting MFA to OPTIONAL rather than ON lets users register a passkey or email code voluntarily, which is the right default for a consumer app. For anything handling financial or health data, switch to ON once you’ve tested the enrollment flow, since a mandatory-MFA pool with an untested UI is a fast way to lock out your first cohort of real users. Reference the WebAuthn.io demo if you want to see the passkey registration ceremony in isolation before wiring it into your own app.
Step 5: Set Password Policy and Account Recovery
Even with passkeys available, most users will still set a password the first time, so the policy needs to be sane rather than theoretical. AWS’s own defaults are reasonable but worth setting explicitly:
aws cognito-idp update-user-pool \
--user-pool-id $USER_POOL_ID \
--policies '{
"PasswordPolicy": {
"MinimumLength": 12,
"RequireUppercase": true,
"RequireLowercase": true,
"RequireNumbers": true,
"RequireSymbols": false,
"TemporaryPasswordValidityDays": 3
}
}' \
--profile cognito-tutorial
Requiring symbols pushes users toward password-manager-generated strings that clear the length bar anyway, so dropping that requirement in favor of a 12-character minimum tends to produce stronger real-world passwords with less user friction, a trade-off backed by OWASP’s Authentication Cheat Sheet. The account-recovery-setting from Step 2 already routes password resets through verified email, which is the safer default over SMS given how common SIM-swapping attacks have become.
Step 6: Connect Google as a Social Identity Provider
Social login is optional for this project, but it’s the step most readers ask about, so here’s how to wire in Google using OAuth credentials from the Google Cloud Console.
aws cognito-idp create-identity-provider \
--user-pool-id $USER_POOL_ID \
--provider-name Google \
--provider-type Google \
--provider-details '{
"client_id": "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com",
"client_secret": "YOUR_GOOGLE_CLIENT_SECRET",
"authorize_scopes": "openid email profile"
}' \
--attribute-mapping '{
"email": "email",
"username": "sub"
}' \
--profile cognito-tutorial
In the Google Cloud Console, the authorized redirect URI has to match your Cognito domain exactly: https://tasktracker-app.auth.us-east-1.amazoncognito.com/oauth2/idpresponse. A mismatched redirect URI is the single most common failure mode here, and Google’s error message (“redirect_uri_mismatch”) gives no hint that the fix lives in Cognito rather than in your app code.
Step 7: Create an App Client With Authorization Code + PKCE
An app client is the credential your front end uses to talk to the user pool. For any browser-based or mobile app, use the Authorization Code flow with PKCE (Proof Key for Code Exchange) and never generate a client secret, since a secret embedded in JavaScript or a mobile binary isn’t actually secret.
aws cognito-idp create-user-pool-client \
--user-pool-id $USER_POOL_ID \
--client-name "tasktracker-web" \
--no-generate-secret \
--allowed-o-auth-flows code \
--allowed-o-auth-scopes openid email profile \
--allowed-o-auth-flows-user-pool-client \
--supported-identity-providers COGNITO Google \
--callback-urls "https://tasktracker-app.com/callback" "http://localhost:3000/callback" \
--logout-urls "https://tasktracker-app.com/logout" "http://localhost:3000/logout" \
--explicit-auth-flows ALLOW_USER_SRP_AUTH ALLOW_REFRESH_TOKEN_AUTH \
--profile cognito-tutorial
Note the two callback URLs: one for production, one for local development. Cognito matches callback URLs exactly, including trailing slashes, so keeping both registered saves you from re-running this command every time you switch between testing locally and deploying.
Step 8: Test Sign-Up and Sign-In Through the Hosted UI
With the domain, MFA config, and app client in place, the Managed Login page is ready to test end to end. Build the authorization URL and open it in a browser:
https://tasktracker-app.auth.us-east-1.amazoncognito.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&scope=openid+email+profile&redirect_uri=http://localhost:3000/callback
Sign up with a real email address you can check, confirm the verification code, and you’ll land back at your callback URL with a code query parameter. Exchange it for tokens:
curl -X POST https://tasktracker-app.auth.us-east-1.amazoncognito.com/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&client_id=YOUR_CLIENT_ID&code=THE_CODE_FROM_REDIRECT&redirect_uri=http://localhost:3000/callback"
A working response looks like this:
{
"id_token": "eyJraWQiOiJ...",
"access_token": "eyJraWQiOiJ...",
"refresh_token": "eyJjdHkiOiJ...",
"expires_in": 3600,
"token_type": "Bearer"
}
Paste the access_token into jwt.io to inspect its claims without writing any code. You should see a token_use claim of access, a client_id matching what you created in Step 7, and an expiry an hour out. That token is what Step 9 will validate on every API call.
Step 9: Protect an API Gateway and Lambda Backend With a JWT Authorizer
API Gateway HTTP APIs support a native JWT authorizer that validates a Cognito access token before your Lambda function ever runs, so you don’t hand-roll token verification inside every handler. Create the API and wire in the authorizer:
aws apigatewayv2 create-api \
--name tasktracker-api \
--protocol-type HTTP \
--profile cognito-tutorial
export API_ID="your-api-id-here"
aws apigatewayv2 create-authorizer \
--api-id $API_ID \
--authorizer-type JWT \
--identity-source '$request.header.Authorization' \
--name cognito-jwt-authorizer \
--jwt-configuration Audience=YOUR_CLIENT_ID,Issuer=https://cognito-idp.us-east-1.amazonaws.com/$USER_POOL_ID \
--profile cognito-tutorial
The Lambda function behind this route doesn’t need to verify the token itself, since API Gateway already rejected the request if the signature, issuer, or audience didn’t match. It only needs to read the claims API Gateway forwards:
exports.handler = async (event) => {
const claims = event.requestContext.authorizer.jwt.claims;
const userId = claims.sub;
return {
statusCode: 200,
body: JSON.stringify({
message: `Tasks for user ${userId}`,
tasks: []
})
};
};
Call the route with the access token from Step 8 and you should get a 200. Drop the Authorization header entirely and you should get a 401 before your Lambda code runs at all, which you can confirm by checking that no new CloudWatch log stream appeared for that request.
Step 10: Add an Identity Pool for Temporary AWS Credentials
The task tracker’s file-upload feature lets a signed-in user drop an attachment straight into S3 from the browser, which needs temporary AWS credentials scoped to that one user’s folder. That’s what an identity pool provides.
aws cognito-identity create-identity-pool \
--identity-pool-name "tasktracker_identity_pool" \
--no-allow-unauthenticated-identities \
--cognito-identity-providers '[{
"ProviderName": "cognito-idp.us-east-1.amazonaws.com/'"$USER_POOL_ID"'",
"ClientId": "YOUR_CLIENT_ID",
"ServerSideTokenCheck": true
}]' \
--profile cognito-tutorial
The identity pool needs an IAM role to hand out, and that role should scope S3 access to a path keyed by the user’s own Cognito identity ID, so one user can never read another user’s uploads:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::tasktracker-uploads/${cognito-identity.amazonaws.com:sub}/*"
}]
}
The ${cognito-identity.amazonaws.com:sub} policy variable is what makes this scoping work automatically per user, without you writing per-user IAM policies by hand. Attach this role to the identity pool through set-identity-pool-roles, and the front end can request S3-scoped credentials by presenting the ID token from Step 8.
Step 11: Add Lambda Triggers for Custom Sign-Up Logic
Cognito exposes lifecycle hooks called Lambda triggers, and two of them cover most real-world customization needs: Pre Sign-up (runs before an account is created) and Post Confirmation (runs after email verification succeeds). For the task tracker, Post Confirmation writes a new row into a DynamoDB user table so the app has a place to store preferences beyond what Cognito’s own attributes hold.
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { PutItemCommand } = require("@aws-sdk/lib-dynamodb");
const client = new DynamoDBClient({});
exports.handler = async (event) => {
await client.send(new PutItemCommand({
TableName: "TaskTrackerUsers",
Item: {
userId: { S: event.request.userAttributes.sub },
email: { S: event.request.userAttributes.email },
createdAt: { S: new Date().toISOString() }
}
}));
return event;
};
Wire it to the pool with:
aws cognito-idp update-user-pool \
--user-pool-id $USER_POOL_ID \
--lambda-config '{"PostConfirmation": "arn:aws:lambda:us-east-1:ACCOUNT_ID:function:tasktracker-post-confirmation"}' \
--profile cognito-tutorial
Returning event unmodified at the end is required. Cognito’s trigger contract expects the same shape it sent, and forgetting this line is a near-guaranteed way to make sign-up hang or fail with a vague timeout after 5 seconds, since that’s the trigger’s execution limit.
Step 12: Automate the Whole Stack With Terraform
Once you’ve confirmed the manual setup works, tear it down and rebuild it as code. This is the version you’ll actually maintain. Here’s the core of the module, using the aws_cognito_user_pool resource from the Terraform AWS provider:
resource "aws_cognito_user_pool" "tasktracker" {
name = "tasktracker-users"
user_pool_tier = "ESSENTIALS"
username_attributes = ["email"]
auto_verified_attributes = ["email"]
password_policy {
minimum_length = 12
require_uppercase = true
require_lowercase = true
require_numbers = true
require_symbols = false
}
account_recovery_setting {
recovery_mechanism {
name = "verified_email"
priority = 1
}
}
}
resource "aws_cognito_user_pool_domain" "tasktracker" {
domain = "tasktracker-app"
user_pool_id = aws_cognito_user_pool.tasktracker.id
managed_login_version = 2
}
resource "aws_cognito_user_pool_client" "web" {
name = "tasktracker-web"
user_pool_id = aws_cognito_user_pool.tasktracker.id
generate_secret = false
allowed_oauth_flows = ["code"]
allowed_oauth_scopes = ["openid", "email", "profile"]
allowed_oauth_flows_user_pool_client = true
callback_urls = ["https://tasktracker-app.com/callback"]
supported_identity_providers = ["COGNITO"]
explicit_auth_flows = ["ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]
}
Run terraform plan before apply and read the diff. A blank plan for the user pool tier field usually means Terraform’s AWS provider version predates when this argument was added, so pin your provider to a version from mid-2025 or later, since user_pool_tier support landed alongside AWS’s own tier rollout.
Step 13: Set Up CloudWatch Alarms and Rate-Limit Monitoring
Cognito enforces API request quotas per account and region, and a bug in a retry loop can burn through those quotas fast enough to lock out real users during an incident, not just during load testing. Set an alarm on throttled requests before you need it, not after.
aws cloudwatch put-metric-alarm \
--alarm-name "cognito-throttle-alert" \
--namespace "AWS/Cognito" \
--metric-name "ThrottledRequestCount" \
--dimensions Name=UserPool,Value=$USER_POOL_ID \
--statistic Sum \
--period 300 \
--threshold 10 \
--comparison-operator GreaterThanThreshold \
--evaluation-periods 1 \
--profile cognito-tutorial
Pair this with a sign-in failure rate alarm. A spike in failed logins with no matching spike in traffic is one of the earliest signals of credential stuffing against your user pool, and Cognito’s built-in threat protection features (Plus tier) surface this automatically, but a basic CloudWatch alarm on Essentials gets you most of the way there for free.
6 Common Pitfalls When Setting Up Amazon Cognito
1. Assuming every pool defaults to the tier you want. Essentials is the default for brand-new pools, but pools created before the tier system existed were migrated onto specific tiers by AWS, and that migration doesn’t always match what a given app actually needs. Check UserPoolTier explicitly rather than assuming.
2. Storing the client secret for a public app client. If your app runs in a browser or a mobile binary, generating a client secret and shipping it in that code is worse than having no secret at all, since it creates a false sense of security. Always use --no-generate-secret for public clients.
3. Mismatched callback URLs between environments. Cognito matches callback URLs as exact strings. A trailing slash difference between your local dev URL and your registered callback will produce a generic “redirect_mismatch” error that gives no indication of which character is wrong.
4. Forgetting that Lambda triggers must return the event object. Every Cognito trigger, from Pre Sign-up to Post Confirmation, expects the handler to return the same event object it received, sometimes with specific fields modified. Returning anything else, or returning nothing, breaks the sign-up flow for every user, not just edge cases.
5. Confusing the ID token with the access token. The ID token describes who the user is and should never be sent to your own API as a bearer token. The access token is what your API Gateway JWT authorizer expects. Sending the wrong one produces a confusing “invalid audience” error since the two tokens have different audience claims.
6. Skipping the account recovery configuration. Without an explicit account-recovery-setting, some pools default to SMS-based recovery, which costs extra per message and is weaker against SIM-swap attacks than email-based recovery. Set it explicitly in Step 2 rather than trusting the default.
Amazon Cognito vs Auth0 vs Clerk vs Firebase Auth
Cognito isn’t the only managed auth option, and teams evaluating it usually have one of these three alternatives already open in another tab. Pricing structures differ enough that the cheapest option depends entirely on your user count.
| Service | Free tier | Paid pricing (approximate) | Best fit |
|---|---|---|---|
| Amazon Cognito (Essentials) | 10,000 MAU | $0.015/MAU flat | Teams already deep in AWS, needing tight IAM/STS integration |
| Auth0 | 25,000 MAU | From ~$35/mo for 500 MAU (B2C Essentials) | Enterprise SSO, extensive rules/actions ecosystem |
| Clerk | Up to 50,000 monthly recurring users (Hobby) | $25/mo + $0.02/MAU above free tier (Pro) | React/Next.js apps wanting prebuilt UI components |
| Firebase Authentication | Free within Firebase’s Spark plan quotas | Usage-based across the broader Firebase platform | Apps already built on Firebase/Google Cloud |
The pattern worth noticing: Cognito’s per-MAU pricing sits between Clerk’s higher per-MAU rate and the much larger free allowances offered by Auth0 and Clerk at low volume. Where Cognito wins isn’t the sticker price at small scale. It’s the native integration with IAM, STS, and API Gateway that this guide leans on in Steps 9 and 10, integration that a third-party auth provider can replicate but never quite as natively.
Advanced Tips for Production Cognito Deployments
Rotate app client credentials on a schedule, not just after an incident. Even without a generated secret, you can revoke and reissue an app client’s ID if you suspect it’s been hard-coded somewhere it shouldn’t be, forcing every active session through a fresh authorization flow.
Use custom attributes sparingly. Custom attributes on a user pool can’t be deleted once created, only marked as unused going forward. Plan your schema before you ship, since a typo’d custom attribute name becomes permanent clutter in every user record for the life of the pool.
Enable advanced security features on Plus if you handle regulated data. The threat protection features gated behind the Plus tier include compromised-credential checks against known breach databases and adaptive authentication that can require step-up MFA when a sign-in looks anomalous. For healthcare or financial apps, that upgrade often costs less than the incident it prevents.
Test token expiry handling before launch, not during an outage. Access tokens expire in an hour by default. Build the refresh-token exchange into your front end early and test it by manually expiring a session, rather than discovering the gap when a real user’s session drops mid-task.
Keep the Lambda trigger execution time well under 5 seconds. Cognito trigger timeouts are strict and non-configurable at 5 seconds for most trigger types. A Post Confirmation trigger that calls a slow third-party API can silently fail sign-ups during that provider’s slow periods, so keep triggers fast and push slower work to an async queue instead.
Security Hardening for Production Cognito Deployments
A user pool holds the keys to every other part of the stack this guide builds, so it deserves the same scrutiny as a production database, not the lighter treatment a “just auth” service sometimes gets.
Never log full token payloads. It’s tempting to console.log the decoded JWT while debugging a Lambda trigger, but access and ID tokens can contain enough user detail to matter under GDPR or CCPA if that log line ends up in a long-retention CloudWatch Logs group. Log the sub claim alone for tracing, and strip the rest before anything hits a log stream you don’t control the retention of.
Scope Lambda trigger execution roles as tightly as the triggers themselves. The Post Confirmation function from Step 11 only needs dynamodb:PutItem on one table, not broad DynamoDB access. A compromised trigger with an over-permissioned role turns a minor bug into an account-wide incident.
Rotate the Google OAuth client secret from Step 6 on a schedule. Cognito stores that secret to complete the server-side token exchange with Google, and if it’s ever exposed, anyone holding it can impersonate your app’s identity provider registration until you rotate it on both the Google Cloud Console and inside Cognito’s identity provider configuration.
Turn on deletion protection before you go live. A single accidental delete-user-pool call during a scripting mistake wipes every registered user permanently, with no recovery path. AWS’s --deletion-protection ACTIVE flag on update-user-pool blocks exactly that failure mode for a negligible operational cost.
Troubleshooting Amazon Cognito: 9 Issues You’ll Actually Hit
1. “NotAuthorizedException: Incorrect username or password.” Cognito deliberately returns this same generic message whether the username or the password was wrong, to avoid leaking which accounts exist. Check for typos in both fields before assuming the account itself is broken.
2. Passkey registration button doesn’t appear in Managed Login. Confirm the pool is on the Essentials or Plus tier and that webauthn-configuration was set with a matching RelyingPartyId. A relying party ID that doesn’t match your actual domain will silently disable the feature rather than throwing a visible error.
3. API Gateway returns 401 even with a valid-looking token. Check that the authorizer’s Issuer field exactly matches https://cognito-idp.REGION.amazonaws.com/USER_POOL_ID with no trailing slash, and that Audience matches the app client ID, not the user pool ID.
4. “redirect_mismatch” from Google during social sign-in. The redirect URI registered in the Google Cloud Console has to point at your Cognito domain’s /oauth2/idpresponse path exactly, not your app’s own callback URL. This is one of the most-reported Google social login errors in Cognito setups.
5. Lambda trigger times out at exactly 5 seconds. Most Cognito triggers enforce a hard 5-second execution limit that can’t be extended through the Lambda console’s own timeout setting. Move any slow work (external API calls, heavy computation) out of the trigger and into a separate asynchronous process.
6. Identity pool credentials return “AccessDenied” on S3. Check the IAM role’s trust policy allows cognito-identity.amazonaws.com as a principal, and confirm the policy variable ${cognito-identity.amazonaws.com:sub} is spelled exactly right, since a typo here fails silently rather than throwing a policy syntax error.
7. Email verification codes never arrive. New user pools often start in Cognito’s sandbox email mode, which caps outbound email and restricts recipients. Move to Amazon SES for production email delivery and verify your sending domain before launch, not after users start complaining.
8. Terraform apply fails with “user_pool_tier is not a valid argument.” This means your pinned AWS provider version predates tier support. Update the provider version constraint in your Terraform configuration and re-run terraform init -upgrade.
9. Users get logged out far more often than the token expiry suggests. Check your front end is actually using the refresh token flow rather than re-prompting for login on every access token expiry. A missing refresh-token exchange is easy to overlook during initial development since short test sessions never hit the one-hour expiry.
Frequently Asked Questions
What’s the difference between the Lite and Essentials tiers?
Lite covers direct sign-in and social login at a lower per-MAU price with volume discounts. Essentials adds passkeys, email MFA, Managed Login, and custom auth flows at a flat $0.015 per MAU, and is the default for new user pools as of 2026.
Do I need an identity pool if I only call my own API?
No. If your backend is API Gateway plus Lambda, a user pool and a JWT authorizer are enough. Identity pools are only necessary when a client needs direct, scoped access to other AWS services like S3 or DynamoDB without a backend in the middle.
Can I upgrade an existing user pool from Lite to Essentials?
AWS’s tier system supports changing a pool’s tier, but the exact upgrade path and any feature migration steps depend on what’s already configured on that pool. Test the change in a non-production pool first.
Is Amazon Cognito free for a small app?
Yes, up to 10,000 monthly active users on Lite or Essentials. Most side projects and early-stage startups stay comfortably inside that free tier for a long time.
Why does my Lambda trigger break sign-up for every user, not just some?
Cognito triggers run synchronously in the sign-up path. An unhandled exception, a missing return of the event object, or a timeout past 5 seconds all block the flow for every user hitting that trigger, not a subset.
How long do Cognito access tokens last by default?
One hour. Refresh tokens last 30 days by default and can be configured up to a longer window, letting a user stay signed in without re-entering credentials as long as the refresh flow is implemented correctly on the client.
Does Cognito support passwordless authentication?
Yes, through passkeys on the Essentials and Plus tiers, and through custom authentication flows using Lambda triggers for magic-link or one-time-code patterns on any tier.
Can I use Cognito without API Gateway, for example with an Express server on EC2 or ECS?
Yes. Any backend can verify a Cognito access token by fetching the user pool’s JSON Web Key Set from its public well-known endpoint and validating the token’s signature, issuer, and expiry directly, using a library like aws-jwt-verify for Node.js. API Gateway’s JWT authorizer just does that verification for you before your code runs, which is convenient but not required.


