Front-End Web & Mobile

Implementing passwordless email authentication with Amazon Cognito

November 2024: The following blog post describes adding Passwordless support to Amazon Cognito using custom auth flows. Amazon Cognito launched native Passwordless support, and using the native functionality should be preferred. However, the blog post below may still be of interest to you, if you want to learn how to implement custom auth flows.

June 2024: This blog post covers Amplify Gen 1. For new Amplify apps, we recommend using Amplify Gen 2. You can learn more about Gen 2 in our launch blog post.

Having to remember passwords can be a pain, especially for passwords that you don’t use often. Like most people, you too are probably familiar with having to click the “forgot password” link or button on websites and apps.

Many people are tempted to use bad practices such as using short passwords, using easily guessable passwords, reusing the same password on many sites and apps, and so on. Even though there are solutions for this (for example, password managers), in practice, password-based security isn’t that safe, and certainly isn’t that user friendly.

There are alternatives to logging in with passwords—for example, using a fingerprint scan or facial recognition. But it’s not always feasible to use such methods.

Amazon Cognito provides you another alternative. What if you didn’t have to enter a password when you log in, but the website or app just sends you a temporary one-time login code, for example, through email, SMS, or a push notification? You retrieve the code, enter it, and you’re in. It’s like a “forgot password” process, but simpler and shorter. Also, it doesn’t carry the notion that you forgot your password.

With Amazon Cognito user pools, you can create custom authentication flows. In this blog post, we demonstrate how this is done by going through a sample implementation of a passwordless authentication flow that sends a one-time login code to the user’s email address.

Overview of the solution

The passwordless email authentication solution uses an Amazon Cognito user pool and a couple of Lambda functions. You use these together to implement the custom authentication flow. You use Amazon Simple Email Service (Amazon SES) for sending the emails with the one-time login codes. Also, the sign-in process is supported by custom UI pages (HTML and JavaScript).

Overview of the solution––diagram

The diagram and the following steps describe the process for the solution.

  1. The user enters their email address on the custom sign-in page, which sends it to the Amazon Cognito user pool.
  2. The user pool calls the “Define Auth Challenge” Lambda function. This Lambda function determines which custom challenge needs to be created.
  3. The user pool calls the “Create Auth Challenge” Lambda function. This Lambda function generates a secret login code and mails it to the user by using Amazon SES.
  4. The user retrieves the secret login code from their mailbox and enters it on the custom sign-in page, which sends it to the user pool.
  5. The user pool calls the “Verify Auth Challenge Response” Lambda function. This Lambda function verifies the code that the user entered.
  6. The user pool calls the “Define Auth Challenge” Lambda function. This Lambda function verifies that the challenge has been successfully answered and that no further challenge is needed. It includes “issueTokens: true” in its response to the user pool. The user pool now considers the user to be authenticated, and sends the user valid JSON Web Tokens (JWTs) (in the response to 4).

Serverless application

The sample implementation is packaged as a serverless application. Deploy it from the AWS Serverless Application Repository, and see how it works.

Relevant parts of the user pool’s configuration are:

  • It requires email addresses as user names.
  • It has an app client that’s configured to “Only allow custom authentication”. This is because when a user signs up, Amazon Cognito requires a password. We’ll supply a random string for this. We don’t want users to be able to actually log in with this password later.
  • The following Lambda functions (Node.js 8.10) that implement the custom authentication flow are configured:
    • Define Auth Challenge – This Lambda function tracks the custom authentication flow, which is comparable to a decider function in a state machine. It determines which challenges should be presented to the user in which order. At the end, it reports back to the user pool if the user succeeded or failed authentication. The Lambda function is invoked at the start of the custom authentication flow and also after each completion of the “Verify Auth Challenge Response” trigger.
    • Create Auth Challenge – This Lambda function is invoked, based on the instruction of the “Define Auth Challenge” trigger, to create a unique challenge for the user. We’ll use it to generate a one-time login code and mail it to the user.
    • Verify Auth Challenge Response – This Lambda function is invoked by the user pool when the user provides the answer to the challenge. Its only job is to determine if that answer is correct.
  • Users can sign up themselves and are auto-confirmed using the pre sign-up user pool trigger. Because email addresses are integral to being able to log in, we don’t need users to separately confirm their email address.

Create Auth Challenge trigger

Read through the code, and you’ll see that it generates a secret login code and mails this to the user. A user has three chances to enter the right code, before needing to be sent a new login code.

import { CognitoUserPoolTriggerHandler } from 'aws-lambda';
import { randomDigits } from 'crypto-secure-random-digit';
import { SES } from 'aws-sdk';

const ses = new SES();

export const handler: CognitoUserPoolTriggerHandler = async event => {

    let secretLoginCode: string;
    if (!event.request.session || !event.request.session.length) {

        // This is a new auth session
        // Generate a new secret login code and mail it to the user
        secretLoginCode = randomDigits(6).join('');
        await sendEmail(event.request.userAttributes.email, secretLoginCode);

    } else {

        // There's an existing session. Don't generate new digits but
        // re-use the code from the current session. This allows the user to
        // make a mistake when keying in the code and to then retry, rather
        // the needing to e-mail the user an all new code again.    
        const previousChallenge = event.request.session.slice(-1)[0];
        secretLoginCode = previousChallenge.challengeMetadata!.match(/CODE-(\d*)/)![1];
    }

    // This is sent back to the client app
    event.response.publicChallengeParameters = {
        email: event.request.userAttributes.email
    };

    // Add the secret login code to the private challenge parameters
    // so it can be verified by the "Verify Auth Challenge Response" trigger
    event.response.privateChallengeParameters = { secretLoginCode };

    // Add the secret login code to the session so it is available
    // in a next invocation of the "Create Auth Challenge" trigger
    event.response.challengeMetadata = `CODE-${secretLoginCode}`;

    return event;
};

async function sendEmail(emailAddress: string, secretLoginCode: string) {
    const params: SES.SendEmailRequest = {
        Destination: { ToAddresses: [emailAddress] },
        Message: {
            Body: {
                Html: {
                    Charset: 'UTF-8',
                    Data: `<html><body><p>This is your secret login code:</p>
                           <h3>${secretLoginCode}</h3></body></html>`
                },
                Text: {
                    Charset: 'UTF-8',
                    Data: `Your secret login code: ${secretLoginCode}`
                }
            },
            Subject: {
                Charset: 'UTF-8',
                Data: 'Your secret login code'
            }
        },
        Source: process.env.SES_FROM_ADDRESS!
    };
    await ses.sendEmail(params).promise();
}

Verify Auth Challenge trigger

All this function has to do is validate that the user’s answer matches the secretLoginCode.

import { CognitoUserPoolTriggerHandler } from 'aws-lambda';

export const handler: CognitoUserPoolTriggerHandler = async event => {
    const expectedAnswer = event.request.privateChallengeParameters!.secretLoginCode; 
    if (event.request.challengeAnswer === expectedAnswer) {
        event.response.answerCorrect = true;
    } else {
        event.response.answerCorrect = false;
    }
    return event;
};

Define Auth Challenge trigger

This is the decider function that manages the authentication flow. In the session array that’s provided to this Lambda function (event.request.session), the entire state of the authentication flow is present.

If it’s empty, the custom authentication flow just started. If it has items, the custom authentication flow is underway: a challenge was presented to the user, the user provided an answer, and it was verified to be right or wrong. In either case, the decider function has to decide what to do next:

import { CognitoUserPoolTriggerHandler } from 'aws-lambda';

export const handler: CognitoUserPoolTriggerHandler = async event => {
    if (event.request.session &&
        event.request.session.find(attempt => attempt.challengeName !== 'CUSTOM_CHALLENGE')) {
        // We only accept custom challenges; fail auth
        event.response.issueTokens = false;
        event.response.failAuthentication = true;
    } else if (event.request.session &&
        event.request.session.length >= 3 &&
        event.request.session.slice(-1)[0].challengeResult === false) {
        // The user provided a wrong answer 3 times; fail auth
        event.response.issueTokens = false;
        event.response.failAuthentication = true;
    } else if (event.request.session &&
        event.request.session.length &&
        event.request.session.slice(-1)[0].challengeName === 'CUSTOM_CHALLENGE' && // Doubly stitched, holds better
        event.request.session.slice(-1)[0].challengeResult === true) {
        // The user provided the right answer; succeed auth
        event.response.issueTokens = true;
        event.response.failAuthentication = false;
    } else {
        // The user did not provide a correct answer yet; present challenge
        event.response.issueTokens = false;
        event.response.failAuthentication = false;
        event.response.challengeName = 'CUSTOM_CHALLENGE';
    }

    return event;
};

Pre sign-up trigger

This function auto-confirms users and their email addresses during sign-up:

import { CognitoUserPoolTriggerHandler } from 'aws-lambda';

export const handler: CognitoUserPoolTriggerHandler = async event => {
    event.response.autoConfirmUser = true;
    event.response.autoVerifyEmail = true;
    return event;
};

Implementing the custom sign-in page

To coordinate with the user pool’s custom authentication flow, a custom sign-in page is needed. You can use the AWS Amplify Framework to integrate your custom sign-in page with Amazon Cognito.

You can implement the custom sign-in page with your favorite framework (React, Angular, Vue, plain HTML/JavaScript, etc.). The following are some JavaScript (TypeScript) examples in the sample solution.

The following shows initializing the AWS Amplify Framework in JavaScript:

import Amplify from 'aws-amplify';

Amplify.configure({
  Auth: {
    region: 'your region',
    userPoolId: 'your userPoolId',
    userPoolWebClientId: 'your clientId',
  }
});

Signing up

For users to be able to sign themselves up, we have to “generate” a password for them, because a password is required by Amazon Cognito when users sign up.

import { Auth } from 'aws-amplify';
export async function signUp(email: string, fullName: string) {
  const params = {
    username: email,
    password: getRandomString(30),
    attributes: {
      name: fullName
    }
  };
  await Auth.signUp(params);
}

function getRandomString(bytes: number) {
  const randomValues = new Uint8Array(bytes);
  window.crypto.getRandomValues(randomValues);
  return Array.from(randomValues).map(intToHex).join('');
}

function intToHex(nr: number) {
  return nr.toString(16).padStart(2, '0');
}

Signing in

Initiate the authentication and start the custom flow:

import { CognitoUser } from 'amazon-cognito-identity-js';

let cognitoUser: CognitoUser; // Track authentication flow state in this object
export async function signIn(email: string) {
    cognitoUser = await Auth.signIn(email);
}

Answering the custom challenge

The user should check their mail and retrieve the secret login code. When the user has entered their secret login code, invoke AWS Amplify to send the secret login code to the user pool as the answer to the custom challenge. Then, one of three things can happen:

  • The user has entered the right code. The user pool responds to AWS Amplify with JWTs, which AWS Amplify stores in the user’s browser (by default in local storage).
  • The user didn’t enter the right code, but it wasn’t the third time yet, so the user has another chance to enter the right code.
  • The user didn’t enter the right code, and it was the third time already, so the authentication failed. The user must go back to the sign-in page and start a new custom authentication flow.
export async function answerCustomChallenge(answer: string) {

    // Send the answer to the User Pool
    // This will throw an error if it’s the 3rd wrong answer
    cognitoUser = await Auth.sendCustomChallengeAnswer(cognitoUser, answer);

    // It we get here, the answer was sent successfully,
    // but it might have been wrong (1st or 2nd time)
    // So we should test if the user is authenticated now
    try {
        // This will throw an error if the user is not yet authenticated:
        await Auth.currentSession();
    } catch {
        console.log('Apparently the user did not enter the right code');
    }

}

Timeout of 3 minutes

Note that after being given the custom challenge, the user has 3 minutes to actually retrieve the secret login code from their email and provide it as the answer. After that, the custom authentication flow times out, and the user has to acquire a new secret login code by starting a new custom authentication flow. This 3-minute timeout is enforced server side by Amazon Cognito. It’s the same as the timeout for code entry with multi-factor authentication (MFA).

Summary

We’ve implemented passwordless authentication with secret login codes sent by email, by using Amazon Cognito custom authentication flows. Depending on the security requirements of your website or app, this solution might work for you as a suitable balance between security and user friendliness. Because the solution is built with Lambda functions, you can extend and adapt it to your needs. Read more about custom authentication in the developer guide and have fun coding!

As always, we’d love to hear from you. You can comment below or reach out to us on the Amazon Cognito Forum.

Resources

Otto Kruse is a professional services consultant based in the Netherlands and focusses on app development practices.