Cognito Lambda Triggers
Overview
Lambda triggers let you inject custom logic into user pool operations (sign-up, sign-in, token
issuance, migration, messaging). This reference covers which trigger to use for what and the
key gotchas. Implementing the Lambda function itself belongs to the aws-serverless skill.
Trigger catalog
| Trigger | Fires when | Use for |
|---|---|---|
| Pre sign-up | A user/admin creates an account | Allow/deny sign-up; auto-confirm for trusted flows (see caveat below) |
| Post confirmation | Account confirmed or password reset | Post-signup provisioning (add to a group, write to a DB, welcome email) |
| Pre authentication | Before sign-in | Block sign-in based on custom rules |
| Post authentication | After successful sign-in | Audit / analytics on login |
| Migrate user | Sign-in or forgot-password for an unknown user | Lazily import users from a legacy directory/DB |
| Pre token generation | Before tokens are issued | Add/modify/suppress token claims (see feature-plan note) |
| Custom message | Cognito sends a verification/MFA message | Customize email/SMS content |
| Define / Create / Verify auth challenge | Custom authentication flow | CAPTCHA, security questions, or a multi-step challenge sequence your app defines. Do NOT use for email/SMS OTP passwordless — Cognito supports those natively as first-auth factors (see passkeys.md for EMAIL_OTP / SMS_OTP in AllowedFirstAuthFactors) |
Pre sign-up — auto-confirm vs auto-verify are different decisions. Auto-confirming an account
(letting the user skip the confirmation code) is fine for trusted flows. Auto-verifying the email
or phone attribute (autoVerifyEmail/autoVerifyPhone) marks that contact as proven-owned, so only
do it when ownership was established out-of-band — e.g. a federated IdP that already verified it.
Never auto-verify based solely on the domain of a self-asserted email, or an attacker can get a
verified attribute they don't own (which then drives password reset / account linking).
Migrate user — lazy import from a legacy directory
The migrate-user trigger fires when Cognito encounters an unknown username during sign-in or forgot-password, letting you validate the credentials against a legacy system and import the user on the fly. Wire this if you're moving 10K+ users off an existing auth database without forcing everyone to reset their password.
Wire BOTH trigger sources for a real lazy-migration setup:
UserMigration_Authentication— fires onInitiateAuth/AdminInitiateAuthfor an unknown user. Receivesevent.request.password(plaintext, so the Lambda can verify against the legacy hash) andevent.request.userAttributes. Must return the user's attributes.UserMigration_ForgotPassword— fires onForgotPasswordfor an unknown user. Does NOT receive the password. Return the user's attributes withemail_verified: "true"so Cognito can send the reset code.
App client must use USER_PASSWORD_AUTH (or ADMIN_USER_PASSWORD_AUTH) — SRP
CANNOT WORK for migration. SRP obscures the plaintext password from Cognito (and
therefore from your Lambda), so the trigger has nothing to verify against the
legacy database. Set ExplicitAuthFlows to include ALLOW_USER_PASSWORD_AUTH on
the app client. Use TLS end-to-end; the plaintext password appears in the raw
Lambda event, so log-sanitize aggressively.
Response contract:
userAttributes— map of Cognito user attributes. Setemail_verified: "true"(orphone_number_verified: "true") to skip re-verification.finalUserStatus— usually"CONFIRMED"so the user can sign in immediately. The default is"RESET_REQUIRED", which forces a password reset on next sign-in.messageAction—"SUPPRESS"to skip the default welcome message.
The pool's own password policy is NOT enforced during migration — the legacy hash was created under its own rules. Consider forcing a rotate-on-next-login for users whose legacy hash doesn't meet your current policy.
Pre token generation — feature plan matters
- V1 (
V1_0): customizes the ID token only. Available on the entry-level plan. - V2 (
V2_0): customizes the access token (and ID token). Requires a paid feature plan — check the Cognito feature plans documentation for which plan currently includes this. - V3 (
V3_0): like V2, and also applies to machine-to-machine (client-credentials) access tokens. Requires a paid feature plan — same caveat, verify against current docs.
Verify the feature-plan requirement against the AWS documentation — plan names and inclusions can change. Do not assume access-token claim customization works on the entry-level plan; choose the trigger version to match the fields your function expects.
Custom authentication challenge flow
Wire the three challenge triggers only when you need a custom challenge sequence your app defines: CAPTCHA on high-risk sign-in, security-question fallback, hardware-token wrap, or a multi-step approval flow. It runs as three triggers in sequence:
- Define auth challenge — the state machine: decides the next challenge or issues tokens.
- Create auth challenge — generates and delivers the challenge.
- Verify auth challenge response — checks the user's answer, sets
answerCorrect.
Drive it from the client with initiate-auth --auth-flow CUSTOM_AUTH then
respond-to-auth-challenge.
Do NOT use this flow for email/SMS OTP passwordless. Cognito offers native email/SMS
OTP as first-auth factors on supported feature plans, without any custom triggers — set
EMAIL_OTP or SMS_OTP in the pool's SignInPolicy.AllowedFirstAuthFactors and use the
USER_AUTH flow. Custom-auth adds three Lambdas, three invoke-permission grants, and a
bespoke state machine to maintain — the native path is a single update-user-pool call.
See passkeys.md for the USER_AUTH flow and AllowedFirstAuthFactors mechanics
(the same wiring covers passkeys, email OTP, and SMS OTP).
Notification recipients (custom message / MFA)
Send verification codes and MFA messages only to attributes that are already verified, or are being verified in this same flow (e.g. the address the user just entered at sign-up, before it's confirmed) — never to an unverified, caller-supplied address that was set for another purpose (e.g. an attribute updated outside the verification flow). Otherwise a caller-controlled email/phone value could be used to redirect a code meant for the account owner to an address the caller chose.
Gotchas
A trigger that returns an error halts the originating Cognito API call and returns an error to the user. Fail closed for triggers that make an authentication or authorization decision (pre sign-up, pre authentication, verify auth challenge, pre token generation, migrate user): return an error rather than allowing the operation to proceed on an unexpected condition or a failed dependency call. Fail-open is acceptable only for non-authoritative side-effect triggers such as post-authentication analytics or custom messaging, where an error should not block the user's sign-in. Example deny path (pre sign-up, Node.js):
if (isBlockedDomain(event.request.userAttributes.email)) { throw new Error("Sign-up not permitted for this domain."); // halts sign-up; do not return normally }Triggers must return the event with the expected response fields populated, or Cognito rejects the response.
Keep triggers fast; they run inline on the auth path and add latency to every affected request.
Grant Cognito permission to invoke the function (
lambda:InvokeFunctionforcognito-idp.amazonaws.comwith the pool as source). The console and CDK wire this automatically — the CDKUserPoolconstruct adds theAWS::Lambda::Permissionwhen you setlambdaTriggersor calladdTrigger(note it setsaws:SourceArnbut notaws:SourceAccount). Only the raw CLI / API / CloudFormation paths need a manuallambda add-permission/AWS::Lambda::Permission. When you add it yourself, include bothaws:SourceArn(the user pool ARN) andaws:SourceAccount(your account id) on the Lambda resource policy to prevent confused-deputy attacks. Via the CLI,--source-arnalone is not enough — you must also pass--source-account, and you must runadd-permissiononce per Lambda you wire (each trigger function needs its own grant):aws lambda add-permission \ --function-name <trigger-fn-name-or-arn> \ --statement-id cognito-<trigger>-invoke \ --action lambda:InvokeFunction \ --principal cognito-idp.amazonaws.com \ --source-arn arn:aws:cognito-idp:<region>:<account>:userpool/<pool-id> \ --source-account <account> # REQUIRED alongside --source-arn; omitting it leaves a confused-deputy gapThe resulting resource-policy statement carries both conditions:
"Condition": { "ArnLike": { "aws:SourceArn": "arn:aws:cognito-idp:<region>:<account>:userpool/<pool-id>" }, "StringEquals": { "aws:SourceAccount": "<account>" } }Trigger functions receive user PII (email, phone, custom attributes). Avoid logging full event payloads, and encrypt the function's CloudWatch Logs group with a KMS key.
Related
- tokens-and-sessions.md for what the claims feed into.
aws-serverlessskill for authoring and deploying the Lambda functions.