Passwordless Authentication made easy with Cognito: a step-by-step guide
Tags: aws, cognito, security, serverless
Published: 2023-03-14 01:49:42

---
title: Passwordless Authentication made easy with Cognito: a step-by-step guide
published: true
date: 2023-03-13 11:02:33 UTC
tags: AWS,Cognito,Security,Serverless
canonical_url: https://theburningmonk.com/2023/03/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](https://dev.to/aws-heroes/the-case-for-and-against-amazon-cognito-2599).

### 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](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html).

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.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f00fb5ef4d.png)

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](https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html) for more details on how to do that.

Here is our authentication flow:

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f011131323.png)

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.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f0140b9b40.png)

Here we can see a user with the specified email is found in the user pool because `userNotFound` is `false`. And 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.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f015a96c91.png)

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

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f0171114fe.png)

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

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f018946b36.png)

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.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f019f6d935.png)

Whatever you put in here would 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](https://lumigo.io/) 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`.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f01be2740c.png)

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`.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f01d3785fb.png)

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`.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f01eaeb404.png)

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!

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f0203720ac.png)

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.

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f021c0cad3.png)

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.

```yml
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`.

```yml
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`.

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

**4. (Frontend) Signing up**

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f024ee52db.png)

When the user signs up for our application, the frontend would generate 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.

```javascript
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**

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f026acbe5e.png)

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

```javascript
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:

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f0280e314b.png)

And here’s what my `DefineAuthChallenge` function looks like.

```javascript
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_](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_InitiateAuth.html#CognitoUserPools-InitiateAuth-response-Session) _API and in the request for the_ [_request of the RespondToAuthChallenge_](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_RespondToAuthChallenge.html#CognitoUserPools-RespondToAuthChallenge-request-Session) _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.

```javascript
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**

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f029e111a3.png)

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.

```javascript
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`.

```javascript
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](https://passwordless-cognito.theburningmonk.com/otp).

![](https://theburningmonk.com/wp-content/uploads/2023/03/img_640f02b8ad507.png)

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

- [Backend (Serverless framework with Node)](https://github.com/theburningmonk/passwordless-otp-cognito-demo)
- [Frontend (Vue3 with Vite)](https://github.com/theburningmonk/passwordless-cognito-ui-demo)

### 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](https://productionreadyserverless.com/?utm_campaign=eventbridge-scheduler-problem&utm_source=blog) where I would be covering topics such as testing, security, observability and much more.

Hope to see you there.

![](https://theburningmonk.com/wp-content/uploads/2022/07/img_62d34443958b3.png)

The post [Passwordless Authentication made easy with Cognito: a step-by-step guide](https://theburningmonk.com/2023/03/passwordless-authentication-made-easy-with-cognito-a-step-by-step-guide/) appeared first on [theburningmonk.com](https://theburningmonk.com).