Cognito user pools let you attach Lambda functions at specific points in the sign-up and sign-in lifecycle. The simplest use is a lightweight hook - auto-confirming trusted test accounts, or syncing a new user into your own database.
The most powerful use replaces password sign-in entirely with a fully custom challenge sequence, such as passwordless email/SMS one-time codes.
Wire the three triggers that together implement a fully custom, passwordless OTP sign-in flow, then walk what each one does when InitiateAuth runs with CUSTOM_AUTH.
# --- Python (boto3) ---import boto3cognito = boto3.client("cognito-idp")# Attach all three custom-auth triggers to the pool in one call.cognito.update_user_pool( UserPoolId=USER_POOL_ID, LambdaConfig={ "DefineAuthChallenge": "arn:aws:lambda:us-east-1:111122223333:function:defineAuthChallenge", "CreateAuthChallenge": "arn:aws:lambda:us-east-1:111122223333:function:createAuthChallenge", "VerifyAuthChallengeResponse": "arn:aws:lambda:us-east-1:111122223333:function:verifyAuthChallenge", },)# Client kicks off custom auth instead of a password check.resp = cognito.initiate_auth( ClientId=APP_CLIENT_ID, AuthFlow="CUSTOM_AUTH", AuthParameters={"USERNAME": "jane@example.com"},)# resp["ChallengeName"] == "CUSTOM_CHALLENGE"; resp["Session"] carries state forward.
// --- TypeScript (AWS SDK v3) ---import { CognitoIdentityProviderClient, UpdateUserPoolCommand, InitiateAuthCommand,} from "@aws-sdk/client-cognito-identity-provider";const cognito = new CognitoIdentityProviderClient({});// Attach all three custom-auth triggers to the pool in one call.await cognito.send(new UpdateUserPoolCommand({ UserPoolId: USER_POOL_ID, LambdaConfig: { DefineAuthChallenge: "arn:aws:lambda:us-east-1:111122223333:function:defineAuthChallenge", CreateAuthChallenge: "arn:aws:lambda:us-east-1:111122223333:function:createAuthChallenge", VerifyAuthChallengeResponse: "arn:aws:lambda:us-east-1:111122223333:function:verifyAuthChallenge", },}));// Client kicks off custom auth instead of a password check.const resp = await cognito.send(new InitiateAuthCommand({ ClientId: APP_CLIENT_ID, AuthFlow: "CUSTOM_AUTH", AuthParameters: { USERNAME: "jane@example.com" },}));// resp.ChallengeName === "CUSTOM_CHALLENGE"; resp.Session carries state forward.
What this demonstrates:
All Lambda triggers for a pool are configured through the same LambdaConfig object on UpdateUserPool.
AuthFlow: "CUSTOM_AUTH" on InitiateAuth is what routes sign-in into the custom challenge loop instead of a password check.
The client-facing shape (a ChallengeName plus a Session string) is identical to the MFA challenge flow covered elsewhere in this section.
No password is involved anywhere in this exchange - the three Lambda functions define what "proving identity" even means.
A fully custom flow chains three triggers in a loop that Cognito drives on your behalf:
DefineAuthChallenge - runs first on every step; decides whether to issue a challenge, fail, or succeed, based on the session's history so far.
CreateAuthChallenge - generates the actual challenge, e.g. creating a one-time code and sending it via SES/SNS, and returns public challenge parameters to the client.
VerifyAuthChallengeResponse - checks the client's submitted answer (e.g. the OTP the user typed) against the private challenge parameters and returns a pass/fail verdict.
Cognito calls DefineAuthChallenge again after each verification, so it can decide "issue another challenge," "fail the session," or "succeed and issue tokens" - this is what lets you build multi-step flows (e.g. OTP, then a device check) from these three functions alone.
The client answers each CUSTOM_CHALLENGE by calling RespondToAuthChallenge with the same Session string InitiateAuth returned and a ChallengeResponses map containing the user's answer (e.g. ANSWER: "482913"). Cognito routes that answer to VerifyAuthChallengeResponse, then re-runs DefineAuthChallenge to decide the next step.
Slow Lambda triggers blocking sign-in - triggers run synchronously in the request path; a slow CreateAuthChallenge (e.g. a slow SES call) directly slows down sign-in for every user. Fix: keep trigger logic fast, and move non-blocking side effects (like a welcome email) to PostConfirmation instead of the sign-in-critical path.
A failing trigger breaks the entire flow - if PreSignUp throws or times out, SignUp itself fails for the user. Fix: handle errors defensively inside the Lambda and fail closed only when you actually intend to block the user.
Forgetting PreSignUp auto-confirm needs an explicit flag - the trigger must set event.response.autoConfirmUser = true (and autoVerifyEmail/autoVerifyPhone as needed); Cognito does not infer intent from a 200 response alone. Fix: always set the response fields explicitly.
Reusing one Lambda across unrelated pools - trigger events include a userPoolId, but sharing code across pools without branching on it risks cross-pool logic bugs. Fix: branch on event.userPoolId or use dedicated functions per pool.
Session replay in custom auth - a client that resubmits an old Session after DefineAuthChallenge marks it failed can loop unexpectedly. Fix: have DefineAuthChallenge cap the number of attempts and fail the session explicitly after a limit.
Assuming triggers can call other AWS services without permission - the trigger's own Lambda execution role, not the user pool, governs what it can do. Fix: grant the Lambda execution role only the specific permissions each trigger needs (e.g. ses:SendEmail).
What is the difference between lifecycle hooks and the custom auth challenge loop?
Lifecycle hooks (PreSignUp, PostConfirmation, etc.) run once at a specific point and don't change how sign-in works. The custom auth loop (DefineAuthChallenge, CreateAuthChallenge, VerifyAuthChallengeResponse) replaces the sign-in mechanism itself.
How do I attach a Lambda trigger to a pool?
Call UpdateUserPool with a LambdaConfig object mapping trigger names to function ARNs. Any subset of triggers can be configured in one call.
What AuthFlow value starts a custom auth sign-in?
CUSTOM_AUTH on InitiateAuth. Cognito responds with a ChallengeName of CUSTOM_CHALLENGE and a Session string instead of checking a password.
Which trigger actually generates the one-time code in a passwordless flow?
CreateAuthChallenge. It builds the challenge (e.g. generates and sends an OTP) and returns public parameters to the client, keeping the correct answer private for VerifyAuthChallengeResponse to check later.
What decides whether the sign-in eventually succeeds?
DefineAuthChallenge. Cognito calls it after each verification step, and it decides whether to issue another challenge, fail the session, or mark it as a success (which triggers token issuance).
Do Lambda triggers run synchronously during sign-in?
Yes. They run inline in the request path, so a slow or failing trigger directly delays or blocks the user's sign-up or sign-in.
How does PreSignUp auto-confirm a user?
The Lambda must explicitly set event.response.autoConfirmUser = true in its response payload. Returning success alone does not auto-confirm anything.
What permissions does a Cognito Lambda trigger need?
Whatever its own logic requires (e.g. ses:SendEmail to send an OTP), granted via the Lambda's own execution role - the user pool itself grants no permissions to the trigger.