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:

  1. 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.
  2. Retry Flow:
    • If the OTP is incorrect, DefineAuthChallenge sets challengeName: '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.
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;
};