This guide will demonstrate how to use the pre-built UI in popup mode to present a passwordless challenge to the user on login. This can be achieved in just a few lines of code by using our Authsignal Node SDK in your Cognito lambdas as well as our Authsignal Web SDK in your web app.

The example uses email OTP but the same approach can also be used for other authentication methods such as authenticator app, SMS or WhatsApp OTP, or email magic link.

If you want to see how to add passkeys when using Amplify in a web app, you can check out this follow-up guide.

Example code

You can find the full example code on Github.

This example uses major version 6 of the aws-amplify js client library. If you are using version 5 then there are some minor differences which you can see here.

User flow

Passwordless login

Passwordless login via an email OTP challenge

Password + MFA

It’s also possible to adapt the passwordless login example to instead require a password followed by an MFA step.

Password + MFA via a TOTP challenge

You can see more detail on how to implement login with password and MFA in this Github branch.

Installation

yarn install

Configuration

Rename the example env config file from .env.example to .env then update it with values for your Authsignal tenant and Cognito user pool.

The lambdas

The example repo contains four lambdas which can be deployed to your AWS environment.

Once deployed, these lambdas can be connected to your Cognito user pool:

Create Auth Challenge lambda

This lambda uses the Authsignal Node.js SDK to return a short-lived token back to the app which can be passed to the Authsignal Web SDK to launch the Authsignal pre-built UI in a popup:

export const handler: CreateAuthChallengeTriggerHandler = async (event) => {
  const userId = event.request.userAttributes.sub;
  const email = event.request.userAttributes.email;

  // This can be any value which defines your login action
  const action = "cognitoAuth";

  const { url } = await authsignal.track({
    userId,
    action,
    attributes: {
      email,
    },
  });

  event.response.publicChallengeParameters = { url };

  return event;
};

Verify Auth Challenge Response lambda

This lambda takes the result token returned by the Authsignal Web SDK and passes it to the Authsignal Node.js SDK to validate the result of the challenge:

export const handler: VerifyAuthChallengeResponseTriggerHandler = async (event) => {
  const token = event.request.challengeAnswer;
  const userId = event.request.userAttributes.sub;

  // This must be the same value used in the previous step
  const action = "cognitoAuth";

  const { state } = await authsignal.validateChallenge({
    token,
    userId,
    action,
  });

  event.response.answerCorrect = state === "CHALLENGE_SUCCEEDED";

  return event;
};

The SPA

Running the app

Run the app with the following command:

yarn dev

Sign up

The example app only has a “Sign in” page - as part of this flow we try to create the user in Cognito first and if they already exist we simply ignore the error and continue.

try {
  const signUpInput: SignUpInput = {
    username: email,
    password: Math.random().toString(36).slice(-16) + "X", // Dummy value - never used
    options: {
      userAttributes: {
        email,
      },
    },
  };

  await signUp(signUpInput);
} catch (ex) {
  if (ex instanceof Error && ex.name !== "UsernameExistsException") {
    throw ex;
  }
}

Similar to the example in this AWS blog post, a dummy password is randomly generated because Amplify requires one when signing up, but it won’t actually be used.

Sign in

We call the Amplify signIn method, which invokes the Create Auth Challenge lambda and returns a URL for the pre-built UI. We pass this URL to the Authsignal Web SDK, which opens it in a popup or modal to present the challenge. Once the user has completed the challenge, the Authsignal Web SDK returns a token. We pass this token back to the Amplify confirmSignIn method, which invokes the Verify Auth Challenge Response lambda.

const signInInput = {
  username: email,
  options: {
    authFlowType: "CUSTOM_WITHOUT_SRP",
  },
};

const { nextStep } = await signIn(signInInput);

const url = nextStep.additionalInfo.url;

const { token } = await authsignal.launch(url, { mode: "popup" });

await confirmSignIn({ challengeResponse: token });

Next steps