Passwordless Authentication made easy with Cognito: a step-by-step guide

Password-based authentication has long been the norm for securing user accounts. However, it is becoming increasingly clear that password-based authentication has several drawbacks. Such as the risk of password theft, the need for users to remember complex passwords, and the time and effort required to reset forgotten passwords.

Fortunately, more and more websites have started to adopt passwordless authentication. As the name suggests, it’s a means to verify a user’s identity without using passwords.

In this blog post, we will explore how to implement passwordless authentication with Amazon Cognito.

Amazon Cognito is a fully managed service that provides user sign-up, sign-in, and access control. Its direct integration with other AWS services such as API Gateway, AppSync and Lambda makes it one of the easiest ways to add authentication and authorization to applications running in AWS. And it’s also one of the most cost-efficient products on the market, compared to the likes of Auth0 and Okta.

If you want to see a full breakdown of the case for and against Cognito, then check out my article on the topic [1].

Passwordless authentication with Cognito

Passwordless authentication can be implemented in many ways, such as:

  • Biometrics: think Face IDs or thumbprints.
  • Possession factors: something the user owns, such as an email address or phone number. If a user can open an account with you using email then you can authenticate the user by sending a one-time password (OTP) to the user’s email.
  • Magic links: the user enters their email, and you send them an email with a special link (aka the “magic link”). When the user clicks the link, it takes them to the application and grants them access.

Cognito doesn’t support passwordless authentication out-of-the-box. But you can implement custom authentication flows using its Lambda hooks [2].

In this blog post, I will show you how to implement passwordless authentication using one-time passwords.

How it works

For this solution, I would use these three Lambda hooks to implement the custom authentication flow.

I will use the Amazon Simple Email Service (SES) to send emails to the user. If you wish to try it out yourself, you would need to create and verify a domain identity in SES. Please refer to the official documentation page [3] for more details on how to do that.

Here is our authentication flow:

1. The user initiates the authentication flow with their email address.

2. The user pool calls the DefineAuthChallenge Lambda function to decide what it should do. The function receives an invocation payload like the one below.

Here we can see a user with the specified email is found in the user pool because userNotFound is false. We can infer that this is the start of a new authentication flow because the session array is empty.

So the function instructs the user pool to issue a CUSTOM_CHALLENGE to the user as the next step. As you can see in the return value from this Lambda invocation.

3. To create the custom challenge, the user pool calls the CreateAuthChallenge Lambda function.

The function generates a one-time password and emails it to the user, using the Simple Email Service (SES).

Crucially, this function needs to save the one-time password somewhere so we can verify the user’s answer later. You can do this by saving private data in the response.privateChallengeParameters object.

Whatever you put in here will not be returned to the user. But it would be passed along to the VerifyAuthChallengeResponse function when the user responds to our challenge.

Any information that you wish to pass back to the frontend can be added to the response.publicChallengeParameters object. Here I’m including the user’s email as well as information about how many attempts the user has left to answer with the right code.

In the screenshot, you can see that Lumigo [4] has scrubbed the one-time password (in response.privateChallengeParameters.secretLoginCode) from the trace. This is a built-in behaviour where it scrubs any data that looks like secrets or sensitive data. But I can tell you that the one-time password is XQezeO in this case. Because it is also captured in response.challengeMetadata, which we would circle back to later.

4. The user enters the one-time password on the login screen.

5. The user pool calls the VerifyAuthChallengeResponse Lambda function to check the user’s answer. As you can see from the invocation event below, we can see both:

  • the user’s answer (request.challengeAnswer), and
  • the one-time password that CreateAuthChallenge function had generated and saved aside in request.privateChallengeParameters.

We can compare the two and tell the user pool if the user has answered correctly by setting response.answerCorrect to true or false.

6. The user pool calls the DefineAuthChallenge function again to decide what happens next. In the invocation event below, you can see that the session array now has one element. The challengeResult is whatever the VerifyAuthChallengeResponse returned in response.answerCorrect.

At this point, we have a few options:

  • Fail the authentication because the user has answered incorrectly too many times.
  • Succeed the authentication flow and issue the JWT tokens to the user.
  • Give the user another chance to answer correctly.

The first two cases are fairly straightforward. The DefineAuthChallenge function would need to set response.failAuthentication or response.issueTokens to true respectively.

Where it gets more interesting is if we want to give the user another chance. In that case, we set both response.issueTokens and response.failAuthentication to false and response.challengeName to CUSTOM_CHALLENGE.

The control would then flow back to the CreateAuthChallenge function. But as you can see below, the privateChallengeParameters we had set aside earlier is not included in its invocation event!

This is why we included the one-time password in the response.challengeMetadata earlier in step 3!

This way, the CreateAuthChallenge function is able to reuse the same one-time password as before. And judging by the number of items in the request.session array, it knows how many failed attempts the user has made. So it’s also able to inform the frontend how many attempts the user has left before the user has to restart the authentication flow and get a new one-time password.

I hope this gives you a solid conceptual framework of how the authentication flow works.

Now let’s talk about implementation.

How to implement it

1. Set up a Cognito User Pool

First, we need to set up a Cognito User Pool.

PasswordlessOtpUserPool:
  Type: AWS::Cognito::UserPool
  Properties:
    UsernameConfiguration:
      CaseSensitive: false
    UsernameAttributes:
      - email
    Policies:
      # this is only to satisfy Cognito requirements
      # we won't be using passwords, but we also don't
      # want weak passwords in the system ;-)
      PasswordPolicy:
        MinimumLength: 16
        RequireLowercase: true
        RequireNumbers: true
        RequireUppercase: true
        RequireSymbols: true
    Schema:
      - AttributeDataType: String
        Mutable: false
        Required: true
        Name: email
        StringAttributeConstraints: 
          MinLength: '8'
    LambdaConfig:
      PreSignUp: !GetAtt PreSignUpLambdaFunction.Arn
      DefineAuthChallenge: !GetAtt DefineAuthChallengeLambdaFunction.Arn
      CreateAuthChallenge: !GetAtt CreateAuthChallengeLambdaFunction.Arn
      VerifyAuthChallengeResponse: !GetAtt VerifyAuthChallengeResponseLambdaFunction.Arn

It’s important to note that passwords are still required even if you don’t intend to use them. I have set a fair strong password requirement here, but the passwords would be generated by the front end and they are never exposed to the user.

Our user pool is not going to verify the user’s email when they sign up. Because every time the user tries to sign in, we would send them a one-time password to the email. Which would verify their ownership of the email address at that point.

2. Set up the User Pool Client for the frontend

The frontend application needs a client ID to talk to the user pool. Because we don’t want the users to log in with passwords, so we will only support the custom authentication flow with ALLOW_CUSTOM_AUTH.

WebUserPoolClient:
  Type: AWS::Cognito::UserPoolClient
  Properties:
    ClientName: web
    UserPoolId: !Ref PasswordlessOtpUserPool
    ExplicitAuthFlows:
      - ALLOW_CUSTOM_AUTH
      - ALLOW_REFRESH_TOKEN_AUTH
    PreventUserExistenceErrors: ENABLED

3. The PreSignUp hook

Normally, a user needs to confirm their registration with a verification code (to prove that they own the email address they used). But as mentioned above, we would skip this verification step because we would verify the user’s ownership of the email every time they attempt to sign in.

So in the PreSignUp Lambda function, we need to tell Cognito to confirm the user by setting event.response.autoConfirmUser to true.

module.exports.handler = async (event) => {
  event.response.autoConfirmUser = true
  return event
}

4. (Frontend) Signing up

When the user signs up for our application, the frontend generates a 16-digit random password behind the scenes. This password is never shown to the user and is essentially thrown away after this point.

The aws-amplify package has a handy Auth module, which we can use to interact with the user pool.

import { Amplify, Auth } from 'aws-amplify'

Amplify.configure({
  Auth: {
    region: ...,
    userPoolId: ...,
    userPoolWebClientId: ...,
    mandatorySignIn: true
  }
})

async function signUp() {
  const chance = new Chance()
  const password = chance.string({ length: 16 })
  await Auth.signUp({
    username: email.value,
    password
  })
}

Again, this is necessary because Cognito requires you to configure passwords even if you don’t intend to use them.

5. (Frontend) Signing in

Once registered, the user can sign in by providing only their email address.

async function signIn() {
  cognitoUser = await Auth.signIn(email.value)
}

This kickstarts the custom authentication flow.

6. The DefineAuthChallenge function

Cognito’s custom authentication flow behaves like a state machine. The DefineAuthChallenge function is the decision maker and instructs the user pool on what to do next every time something important happens.

As you can see from the overview of the solution, this function is engaged multiple times during an authentication session:

  • when the user initiates authentication, and
  • every time the user responds to an auth challenge.

This is the state machine we want to implement:

And here’s what my DefineAuthChallenge function looks like.

const _ = require('lodash')
const { MAX_ATTEMPTS } = require('../lib/constants')

module.exports.handler = async (event) => {
  const attempts = _.size(event.request.session)
  const lastAttempt = _.last(event.request.session)

  if (event.request.session &&
      event.request.session.find(attempt => attempt.challengeName !== 'CUSTOM_CHALLENGE')) {
      // Should never happen, but in case we get anything other
      // than a custom challenge, then something's wrong and we
      // should abort
      event.response.issueTokens = false
      event.response.failAuthentication = true
  } else if (attempts >= MAX_ATTEMPTS && lastAttempt.challengeResult === false) {
      // The user given too many wrong answers in a row
      event.response.issueTokens = false
      event.response.failAuthentication = true
  } else if (attempts >= 1 &&
      lastAttempt.challengeName === 'CUSTOM_CHALLENGE' &&
      lastAttempt.challengeResult === true) {
      // Right answer
      event.response.issueTokens = true
      event.response.failAuthentication = false
  } else {
      // Wrong answer, try again
      event.response.issueTokens = false
      event.response.failAuthentication = false
      event.response.challengeName = 'CUSTOM_CHALLENGE'
  }

  return event
}

Note that every time the user makes an attempt to respond to the challenge, the result is recorded in event.request.session.

Sidenote: if you’re wondering how Cognito is able to collate these attempts in one place, it’s because a Session string is passed back-and-forth as the client interacts with the user pool. You can see this in the response of the InitiateAuth [5] API and in the request for the request of the RespondToAuthChallenge [6] API.

7. The CreateAuthChallenge function

The CreateAuthChallenge function is responsible for generating the one-time password and emailing it to the user.

This function can also be invoked multiple times in an authentication session if the user does not provide the right answer at first. Once again, we can use the request.session to work out if we’re dealing with an existing authentication session.

const _ = require('lodash')
const Chance = require('chance')
const chance = new Chance()
const { MAX_ATTEMPTS } = require('../lib/constants')

module.exports.handler = async (event) => {
  let otpCode
  if (!event.request.session || !event.request.session.length) {
    // new auth session
    otpCode = chance.string({ length: 6, alpha: false, symbols: false })
    await sendEmail(event.request.userAttributes.email, otpCode)
  } else {
    // existing session, user has provided a wrong answer, so we need to
    // give them another chance
    const previousChallenge = _.last(event.request.session)
    const challengeMetadata = previousChallenge?.challengeMetadata

    if (challengeMetadata) {
      // challengeMetadata should start with "CODE-", hence index of 5
      otpCode = challengeMetadata.substring(5)
    }
  }

  const attempts = _.size(event.request.session)
  const attemptsLeft = MAX_ATTEMPTS - attempts
  event.response.publicChallengeParameters = {
    email: event.request.userAttributes.email,
    maxAttempts: MAX_ATTEMPTS,
    attempts,
    attemptsLeft
  }

  // NOTE: the private challenge parameters are passed along to the 
  // verify step and is not exposed to the caller
  // need to pass the secret code along so we can verify the user's answer
  event.response.privateChallengeParameters = { 
    secretLoginCode: otpCode
  }

  event.response.challengeMetadata = `CODE-${otpCode}`

  return event
}

The sendEmail function has been omitted here for brevity’s sake. It does what you’d expect and sends the one-time password to the user by email.

8. (Frontend) Answering the challenge

In the frontend, you should have captured the CognitoUser object returned by Auth.signIn. You need it to respond to the custom auth challenge because it contains the Session data that Cognito requires.

async function answerCustomChallenge() {
  // This will throw an error if it’s the 3rd wrong answer
  try {
    const challengeResult = await Auth.sendCustomChallengeAnswer(cognitoUser, secretCode.value)

    if (challengeResult.challengeName) {
      secretCode.value = ''
      attemptsLeft.value = parseInt(challengeResult.challengeParam.attemptsLeft)

      alert(`The code you entered is incorrect. ${attemptsLeft.value} attempts left.`)
    }
  } catch (error) {
    alert('Too many failed attempts. Please try again.')
  }  
}

Note that the publicChallengeParameters returned by the CreateAuthChallenge function is accessible here. So we can find out how many attempts the user has left in the current session.

If the DefineAuthChallenge function tells the user pool to fail authentication, then Auth.sendCustomChallengeAnswer would throw an NotAuthorizedException exception with the message Incorrect username or password.

9. The VerifyAuthChallengeResponse function

The VerifyAuthChallengeResponse function is responsible for checking the user’s answer. To do this, it needs to access the one-time password that the CreateAuthChallenge function generated and stashed aside in the privateChallengeParameters.

module.exports.handler = async (event) => {
  const expectedAnswer = event.request?.privateChallengeParameters?.secretLoginCode
  if (event.request.challengeAnswer === expectedAnswer) {
    event.response.answerCorrect = true
  } else {
    event.response.answerCorrect = false
  }
  
  return event
}

And that’s it.

These are the ingredients you need to implement passwordless authentication with Cognito.

Try it out for yourself

To get a sense of how this passwordless authentication mechanism works, please feel free to try out the demo application here.

And you can find the source code for this demo on GitHub:

Wrap up

I hope you have found this article useful and helps you get more out of Cognito, a somewhat underloved service.

If you want to learn more about building serverless architecture, then check out my upcoming workshop [9] where I will be covering topics such as testing, security, observability and much more.

Hope to see you there.

Links

[1] The case for and against Amazon Cognito

[2] Customizing user pool workflows with Lambda triggers

[3] Creating and verifying identities in Amazon SES

[4] Lumigo, the best troubleshooting platform for serverless

[5] Cognito’s InitiateAuth API

[6] Cognito’s RespondToAuthChallenge API

[7] Repo with the backend code for this demo

[8] Repo with the frontend code for this demo

[9] Production-Ready Serverless workshop