Revolutionizing Authentication: Embracing Passwordless Security with Cognito (Phone Number-OTP)

Devaka Dabare
6 min readJun 26, 2023

--

This article explores the benefits, implementation process, and implications of passwordless authentication using Cognito (Phone Number - OTP). Discover how this innovative approach enhances user experience and strengthens account security, revolutionizing the way we protect sensitive information.

Step 1: — Setup Cognito Pool 🔥

👉🏻Go to the Amazon Cognito console.
👉🏻Select the Phone number as a sign-in option

Create Cognito pool

👉🏻Enable MFA authentication

Enable MFA

👉🏻Create an application client and select ALLOW_CUSTOM_AUTH as the authentication flow

Cognito application client configurations

Step 2: — Create Lambda functions and connect with the Cognito User pool 🔥

Custom authentication challenge Lambda triggers

Amazon Cognito User Pools and custom authentication flows, the use of three separate Lambda triggers allows for increased flexibility and security in the authentication process. Here’s a deeper dive into each of the triggers and why they’re necessary:

👉🏻Define Auth Challenge: This Lambda function is responsible for deciding which challenges need to be presented to the user. This decision can be based on various factors such as the user’s risk level, their location, or the device they’re using. By separating this into a dedicated Lambda function, you have the flexibility to define complex and adaptive rules for when and which challenges should be presented to the user.

For this example, I will create the ‘Define auth challenge’ as below 👇 :

exports.handler = (event, context, callback) => {
console.log(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.length > 0 && event.request.session[0].challengeResult === true) {

event.response.issueTokens = true;
event.response.failAuthentication = false;

} else {
event.response.issueTokens = false;
event.response.failAuthentication = true;
}
callback(null, event);
};

If the session array in the request object of the event is empty (i.e., event.request.session.length === 0). If it is, this means that no previous challenge has been presented to the user yet. So, it sets issueTokens to false (meaning it will not issue authentication tokens yet), failAuthentication to false (meaning it will not fail the authentication yet), and challengeName to ‘CUSTOM_CHALLENGE’ (indicating the type of challenge that it will present to the user).

If the session array is not empty and the challengeResult of the first session is true (i.e., the user has successfully answered a previous challenge), it sets issueTokens to true (meaning it will issue authentication tokens to the user) and failAuthentication to false.

If neither of the above conditions is true (which means that there has been a previous challenge and the user failed to answer it correctly), it sets issueTokens to false and failAuthentication to true (meaning it will fail the authentication).

Finally, it invokes the callback function with null as the first argument (indicating no error occurred) and event as the second argument. This passes the modified event object back to Amazon Cognito.

Read more: — https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-define-auth-challenge.html

👉🏻Create Auth Challenge: Once the challenges to be presented are decided, this Lambda function is responsible for generating the actual challenge. This might involve generating a unique one-time password (OTP), creating a CAPTCHA, or selecting from a pool of security questions. Having this as a separate Lambda function allows you to isolate the logic that generates the challenges and makes it easier to change or update this logic as necessary.

For this example, I will create the ‘Create auth challenge’ as below 👇 :

const { SNSClient, PublishCommand } = require("@aws-sdk/client-sns");

// Define the AWS SNS client
const snsClient = new SNSClient({ region: "us-east-1" });

exports.handler = async (event) => {
console.log(event)

try {

//Generate a random 4-digit number
const OTP = Math.floor(1000 + (Math.random() * 5321));
// const OTP = 121212

//Send the OTP via SMS
const params = {
Message: `Your OTP is ${OTP}`,
PhoneNumber: event.request.userAttributes.phone_number,
};

await snsClient.send(new PublishCommand(params));

console.log("OTP sent via SMS: " + OTP);

event.response.publicChallengeParameters = {};
event.response.privateChallengeParameters = {};

// Set the privateChallengeParameters so they can be verified by the Verify Auth Challenge Response trigger
event.response.publicChallengeParameters.phone = event.request.userAttributes.phone_number;
event.response.privateChallengeParameters.answer = OTP;
event.response.challengeMetadata = 'CUSTOM_CHALLENGE';

return event;

} catch (error) {
console.log(error)

throw error;

}

};

It sets up the challenge parameters in the event object. This includes the public challenge parameters, which are visible to the user, and the private challenge parameters, which are not. In this case, the user's phone number is set as a public challenge parameter, and the OTP is set as a private challenge parameter. This is so that the OTP can be verified later in the "Verify Auth Challenge Response" step. It also sets the challengeMetadata field to 'CUSTOM_CHALLENGE' to specify the type of challenge.

Read more: — https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-create-auth-challenge.html

👉🏻Verify Auth Challenge Response: This Lambda function is responsible for checking the answer or response provided by the user to the challenge. Separating this logic into a dedicated function allows you to have complex verification logic. For example, you might want to allow for minor misspellings in security question answers, or you might want to use a third-party service to verify CAPTCHAs.👇

exports.handler = (event, context, callback) => {

if (event.request.privateChallengeParameters.answer === event.request.challengeAnswer) {
event.response.answerCorrect = true;
} else {
event.response.answerCorrect = false;
}
callback(null, event);
}

This function verifies the response to the custom challenge. If the answer provided by the user matches the expected answer, the function considers the challenge to be correctly answered. Otherwise, it considers the challenge to be incorrectly answered.

Read more: — https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-verify-auth-challenge-response.html

Now go to the Cognito user pool settings and add the above functions as defined.(user_pool -> your_user_pool -> add_lambda_trigger)

Add Lambda Triggers

Step 3: — Create an example React App 🔥

Let's Create an example react application with a login page to test this. 👇

<div>
<form onSubmit={handlePhoneNumberSubmit}>
<label>
Phone Number:
<input type="text" value={phoneNumber} onChange={e => setPhoneNumber(e.target.value)} />
</label>
<input type="submit" value="Submit" />
</form>
<form onSubmit={handleOtpSubmit}>
<label>
OTP:
<input type="text" value={otp} onChange={e => setOtp(e.target.value)} />
</label>
<input type="submit" value="Submit" />
</form>
</div>

Step 4: — Login Request 🔥

To handle cognito calls I will use AWS v3 sdk.👇

import React, { useState } from 'react';
import { CognitoIdentityProviderClient, InitiateAuthCommand, RespondToAuthChallengeCommand } from "@aws-sdk/client-cognito-identity-provider";

const client = new CognitoIdentityProviderClient({ region: "us-east-1" });
const [phoneNumber, setPhoneNumber] = useState('');
const [otp, setOtp] = useState('');
const [session, setSession] = useState(null);

const handlePhoneNumberSubmit = async (event) => {
event.preventDefault();

const command = new InitiateAuthCommand({
AuthFlow: 'CUSTOM_AUTH',
ClientId: 'xxxxxxxxxxxx',
AuthParameters: {
'USERNAME': phoneNumber,
'PASSWORD': 'Abc@1234' // A placeholder password
}
});

try {
const response = await client.send(command);
setSession(response.Session);
} catch (error) {
console.error(error);
}
};

In summary, this code initiates the custom authentication process with Amazon Cognito when the user submits their phone number. The response from Amazon Cognito, which includes session data, is stored in a state variable for later use.

Step 5: — Verify OTP 🔥

const handleOtpSubmit = async (event) => {
event.preventDefault();

const command = new RespondToAuthChallengeCommand({
ChallengeName: 'CUSTOM_CHALLENGE',
ClientId: 'xxxxxxxxxxxxx',
Session: session,
ChallengeResponses: {
'USERNAME': phoneNumber,
'ANSWER': otp
}
});

try {
const response = await client.send(command);
console.log('access token:', response.AuthenticationResult.AccessToken);
} catch (error) {
console.error(error);
}
};

This function is used to respond to the custom authentication challenge. It sends the OTP entered by the user to Amazon Cognito and logs the access token returned upon successful authentication.

Upon successful completion of all challenges in the custom authentication flow with Amazon Cognito User Pools, an authentication token is issued and sent to the client-side application. This token serves as a credential to authenticate the user for subsequent interactions with the application.

Conclusion 🔥🔥

The primary purpose of this article is to demonstrate the simplicity and straightforwardness of setting up a custom authentication flow for our applications using AWS Cognito.

Thank you very much for your time and I hope it was helpful.😎😎😎

Feel free to correct any mistakes or leave any feedback you have.🤩🤩🤩

--

--