
AWS Cognito OTP Retries
Authentication with Amazon Cognito Using Phone OTP
Use Case
Amazon Cognito is used as the sole method for authenticating users based on a phone number One-Time Password (OTP).
Authentication Flow with OTP Retries
The authentication process involves the following steps:
- Initial Flow:
- The user initiates login, triggering the
CreateAuthChallenge
Lambda function to send the first OTP. - The user submits the OTP, triggering the
VerifyAuthChallenge
Lambda function. - The result is passed to the
DefineAuthChallenge
Lambda function, which either:- Issues authentication tokens if the OTP is correct.
- Creates a new challenge if the OTP is incorrect.
- The user initiates login, triggering the
- Retry Flow:
- If the OTP is incorrect,
DefineAuthChallenge
setschallengeName: 'CUSTOM_CHALLENGE'
. - This prompts Cognito to call
CreateAuthChallenge
again for the next attempt. - The previous OTP code is accessible in the
session
object and can be reused as the answer for the new challenge. - If reused, the frontend client must replace the old session with the new session when retrying the OTP, but we can save the cost from sending a new OTP code.
- If the OTP is incorrect,
Create Auth Challenge Lambda Trigger
import { SNS } from "@aws-sdk/client-sns";
export const handler = async (event) => {
console.log(JSON.stringify(event, null, 2));
const sns = new SNS({ region: "ap-southeast-1" });
// Check if this is a resend request (user explicitly asked for new OTP)
const isResendRequest =
event.request.userAttributes["custom:resend"] === "true";
// Check if this is a retry (has previous failed attempts)
const isRetry = event.request.session && event.request.session.length > 0;
let otp;
if (isResendRequest || !isRetry) {
// Generate new OTP for:
// 1. First attempt
// 2. Explicit resend request
otp = Math.floor(100000 + Math.random() * 900000).toString();
// Send OTP via SMS
const phone = event.request.userAttributes.phone_number;
const message = `Your OTP for log in to Jackson Parent App is ${otp}`;
try {
await sns.publish({
Message: message,
PhoneNumber: phone,
MessageAttributes: {
"AWS.SNS.SMS.SenderID": { DataType: "String", StringValue: "MyApp" },
"AWS.SNS.SMS.SMSType": {
DataType: "String",
StringValue: "Transactional",
},
},
});
console.log(`New OTP generated and sent: ${otp}`);
} catch (error) {
console.error("Error sending SMS:", error);
// Continue anyway - user might request resend later
}
} else {
// For normal retries (not explicit resend), reuse existing OTP
const lastChallenge = event.request.session.slice(-1)[0];
otp = lastChallenge.challengeMetadata;
console.log(`Retry detected. Reusing OTP: ${otp}`);
}
// Set challenge parameters
event.response.privateChallengeParameters = {
code: otp,
};
event.response.publicChallengeParameters = {
message: isResendRequest
? "New OTP sent to your phone"
: isRetry
? "Incorrect code, please try again"
: "Enter the OTP sent to your phone",
};
// Store OTP in metadata for retries
event.response.challengeMetadata = otp;
return event;
};
Define Auth Challenge Lambda Trigger
export const handler = async (event) => {
if (event.request.session.length === 0) {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = "CUSTOM_CHALLENGE";
} else if (event.request.session.slice(-1)[0].challengeResult === true) {
event.response.issueTokens = true;
event.response.failAuthentication = false;
} else {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = "CUSTOM_CHALLENGE";
}
return event;
};
Verify Auth Challenge Response Lambda Trigger
export const handler = async (event) => {
const expectedAnswer = event.request.privateChallengeParameters.code;
const userAnswer = event.request.challengeAnswer;
console.log("expectedAnswer", expectedAnswer);
console.log("userAnswer", userAnswer);
event.response.answerCorrect = userAnswer === expectedAnswer;
return event;
};