Passwordless Authentication with Cognito
Tags: cognito, aws, serverless, security
Published: 2020-05-22 16:53:50

---
title: Passwordless Authentication with Cognito
published: true
description: Implementing passwordless authentication using Cognito in a real world scenario.
---
Passwordless authentication is a broad term for any authentication method that doesn't rely on passwords. Implementations typically perform proof of identity based on something that is uniquely associated with a user, such as an e-mail address, a phone, a software one-time password (OTP) generator, or a hardware authentication device like a YubiKey: the user inputs the secret that the system shares with one of those methods, proving its ownership. A passwordless flow essentially skips the "what you know" factor of two-factor authentication. We're epistemological skeptics now.

[Cognito][cog] is an infamous AWS service for identity management. It can do much for us: authentication, access control, and integration with external identity providers. A big advantage over competing solutions is that it integrates well with other AWS services, like [AppSync][appsync].

In this post we'll look at how we can implement passwordless authentication using Cognito to satisfy our requirements, which aren't quite as described in this [AWS blog post][aws-post]. We assume a passing familiarity with Cognito and AWS Lambda. The code examples are Lambda functions written in Go.

[cog]: https://aws.amazon.com/cognito/
[appsync]: https://aws.amazon.com/appsync/
[aws-post]: https://aws.amazon.com/blogs/mobile/implementing-passwordless-email-authentication-with-amazon-cognito/

## "As a user, I want..." <a name="ux" />

The app we're going to work on authenticates users according to the following UX:

1. A user inputs their e-mail or phone number.
2. We send an OTP over e-mail or SMS.
3. The user inputs the code.
4. If this is their first sign-in:
   1. We collect additional data, including a secondary authentication method.
   2. We verify it using a second OTP.

<img alt="Example UI" src="https://dev-to-uploads.s3.amazonaws.com/i/rbj7vn5o0pc0a67ggm9d.png" width="350" />
<img alt="Example UI" src="https://dev-to-uploads.s3.amazonaws.com/i/9qgk45gbsj3fzc7e5p37.png" width="350" />

This is very convenient: users can sign-in using information they know by heart, without needing to use password managers or, worse, their memory (they don't even need to remember whether they've already signed-up). 

These are our requirements:

- [ ] The user must be able to sign-in with their email or phone number
- [ ] There is no separate sign-up process, as it should follow directly from the sign-in
- [ ] We mustn't leak user existence errors through the public API, including Cognito's
- [ ] OTPs are valid only for a brief period of time
- [ ] Users have more than one attempt to input the correct OTP

What features does Cognito offer that help us meet these requirements? Let's find out.

## Cognito Custom Auth

Cognito doesn't have a built-in passwordless feature, but it supports a custom authentication flow implemented as a state machine based on [three Lambda function triggers][triggers] that we need to implement.

1. The _define auth challenge_ Lambda is the state machine coordinator. It returns instructions to Cognito on how the flow should progress. The options are: a challenge is required; the authentication failed; or the authentication succeeded and tokens can be emitted. It is invoked by Cognito when a client calls the [`InititateAuth`][initauth] API.
2. If a challenge is required, the _create auth challenge_ Lambda creates it. It's invoked right after the previous Lambda[^1], and is responsible for delivering the challenge to the user. This can be done through public challenge parameters returned by `InitiateAuth`, or through some other means[^2]. Private challenge parameters are shared with the _verify auth challenge response_ Lambda, typically including the challenge answer.
3. The _verify auth challenge response_ Lambda is invoked by Cognito following the [`RespondToAuthChallenge`][respond] API call, containing the challenge answer provided by the user. It returns whether the answer is correct, and Cognito invokes the _define auth challenge_ Lambda again to decide whether the authentication can terminate or should continue with more challenges (in which case the _create auth challenge_ Lambda gets called again).

<figure>
<img alt="Example UI" src="https://docs.aws.amazon.com/cognito/latest/developerguide/images/lambda-challenges.png" width="450" />
<figcaption>The custom auth state machine, from the <a href="https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html">documentation</a>.
</figure>

A custom authentication flow can be composed of many challenges. State is kept in an encrypted session object, which is initially returned by the `InititateAuth` API call, and then threaded back to Cognito when calling `RespondToAuthChallenge`. It expires after 3 minutes, which already allows us to tick off a requirement!

- [ ] The user must be able to sign-in with their email or phone number
- [ ] There is not separate sign-up process, as it should follow directly from the sign-in
- [ ] We mustn't leak user existence errors through the public API, including Cognito's
- [x] OTPs are valid only for a brief period of time
- [ ] Users have more than one attempt to input the correct OTP

[triggers]: https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html
[initauth]: https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_InitiateAuth.html
[respond]: https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_InitiateAuth.html

[^1]: Yes, an `InitiateAuth` call can hit 2 Lambda cold-boots.

[^2]: For example, by sending an e-mail, SMS, or push notification using some AWS service.

## Configuring the Cognito user pool

Our journey first tasks us with configuring a Cognito user pool in such a way that e-mails and phone numbers can be used as [aliases][aliases]. Thanks to the [CDK][cdk], we don't have to confront the maze of darkness that is CloudFormation YAML:

```ts
this.pool = new UserPool(stack, "UserPool", {
  userPoolName: props.userPoolName,
  signInAliases: { username: true, email: true, phone: true },
  lambdaTriggers: {
    defineAuthChallenge: props.triggers.defineAuthChallenge,
    createAuthChallenge: props.triggers.createAuthChallenge,
    verifyAuthChallengeResponse: props.triggers.verifyAuthChallengeResponse,
  },
  selfSignUpEnabled: false,
})
```
This translates to a user pool that's part of a CloudFormation stack, which:
* Allows users to sign-in using their e-mail address and phone-number;
* Is configured with the ARN of the Lambdas that will carry out our passwordless authentication flow;
* Doesn't allow users to sign themselves up, because [the `SignUp` API can be used as an oracle][oracle] for whether a user exists in a pool. This means we must use the [`AdminCreateUser`][admin] API.

We also configure the user pool client to only authenticate users through the custom authentication flow, thus disallowing password-based authentication. Clients are allowed to refresh user tokens (tokens have a validity and need to be refreshed for the session to be extended without users having to go through the sign-in process again).

```ts
this.pool.addClient(`UserPoolClient${clientName}`, {
  userPoolClientName: clientName,
  authFlows: {
    custom: true,
    refreshToken: true,
  },
})
```

[aliases]: https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html#user-pool-settings-aliases
[cdk]: https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html#user-pool-settings-aliases
[oracle]: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-managing-errors.html
[admin]: https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminCreateUser.html

## Verifying an email or phone number

Implementing the flow using the extension Lambda functions seems straightforward:

1. The _define auth challenge_ Lambda checks for a successfully completed challenge; if there isn't any, it either fails the authentication if there are over N attempts, or keeps the state machine running.
2. The _create auth challenge_ Lambda sends an e-mail or SMS, depending on what was used to sign-in.
3. The _verify auth challenge response_ receives the answer from the client and marks the challenge as correct or not.

Alas, it's not so simple. We need to pass a bit of information from the client to the _create auth challenge_ Lambda, so it can decide between sending an e-mail or an SMS. Cognito, however, doesn't tell the Lambda what alias was used to start the flow, nor does it thread the `ClientMetadata` parameter of `InititateAuth` to the _define_ or _create_ _auth challenge_ Lambdas.

How can we work around this? We could pass that information through a data store like DynamoDB, but a better solution is to leverage that `ClientMetadata` parameter. `InititateAuth` doesn't pass it to the Lambda, but `RespondToAuthChallenge` certainly does. 

### An RPC by any other name

These authentication challenges actually comprise a complete request-response protocol over which the client can communicate with the state machine. Clients can answer the challenges while passing along arbitrary data to the Lambdas, which can keep the protocol going as long as needed. With this in mind, we can solve the problem by using an extra challenge to specify what the actual method we're verifying is:

![Cognito RPC](https://dev-to-uploads.s3.amazonaws.com/i/bu3ezgnlaby6344zbhot.png)

The first challenge delivers no code, and expects `email` or `phone` to be passed in the `ClientMetadata`. The second challenge is the actual OTP.

The metadata returned by the _create auth challenge_ Lambda is kept in the encrypted session object, and is used to communicate with the _define auth challenge_ Lambda as well as with future invocations of the _create auth challenge_ Lambda. The private parameters are shared only with the _verify auth challenge response_ Lambda.

This custom authentication flow now behaves as a primitive that lets us validate a user attribute, namely an e-mail or phone number.

### Implementation

The _verify auth challenge response_ Lambda has the easiest task:

```go
func handler(ctx context.Context, event *events.CognitoEventUserPoolsVerifyAuthChallenge,
) (*events.CognitoEventUserPoolsVerifyAuthChallenge, error) {
	code := event.Request.PrivateChallengeParameters["code"]
	event.Response.AnswerCorrect = code == "" || code == event.Request.ChallengeAnswer
	return event, nil
}
```

If there is no code in the private challenge parameters it's because this is the first step of the protocol, in which case the answer is always accepted. Otherwise, we match the code against the user-provided answer.

The _create auth challenge_ Lambda calculates and delivers the challenge. We encode a challenge as:

```go
type AttributeType string

const (
	Email          AttributeType = "email"
	Phone          AttributeType = "phone_number"
	NoVerification AttributeType = "none"
)

type Challenge struct {
	Attr AttributeType
	Code string
}
```

This challenge is persisted in the metadata shared between all Lambdas in the state machine and stored in the session object. The private challenge parameters are shared with the _verify auth challenge response_ Lambda. If our challenge had public parameters, we would specify them here and they would be sent to the client (in plaintext).

```go
func handler(ctx context.Context, event *events.CognitoEventUserPoolsCreateAuthChallenge,
) (*events.CognitoEventUserPoolsCreateAuthChallenge, error) {
	challenge := calculateAndDeliverChallenge(&event.Request, userTest, &metadata)
	event.Response = triggers.CognitoEventUserPoolsCreateAuthChallengeResponse{
		PrivateChallengeParameters: map[string]string{
			"code": challenge.Code,
		},
		ChallengeMetadata: auth.Serialize(challenge),
	}
	return event, nil
}
```

We generate an empty code for the first challenge and an OTP for the second one. In the second challenge we expect the client metadata to contain the attribute type to verify (the type, _not_ the value), which we use to select the the attribute value from the current user attributes, delivering the code by e-mail or SMS.[^errors] The client essentially sends us a pointer to the actual attribute to verify. 

```go
func calculateAndDeliverChallenge(req *events.CognitoEventUserPoolsCreateAuthChallengeRequest,
) auth.Challenge {
	sessionSize := len(request.Session)
	if sessionSize == 0 {
		return auth.Challenge{auth.NoVerification, ""}
	}
	lastSession := request.Session[sessionSize-1]
	lastChallenge := auth.Deserialize(lastSession.ChallengeMetadata)
	if !lastSession.ChallengeResult {
		return lastChallenge
	}
	attr := auth.AttributeToVerify(request.ClientMetadata, request.UserAttributes)
	code := auth.GenerateOTP()
	deliverCode(attr, code)
	return auth.Challenge{attr.Type, code}
}
```

[^errors]: Cognito doesn't support returning custom errors from the extension Lambdas. Errors must be propagated in the state machine in such a way that authentication will end up failing.

If the previous challenge failed, that is, if the user introduced the wrong code, we reuse it.

The _define auth challenge_ Lambda coordinates these two:

```go
type ChallengeStatus struct {
  Challenge		 auth.Challenge
	AttemptCount int
	Passed       bool
}

func handler(ctx context.Context, event *events.CognitoEventUserPoolsDefineAuthChallenge,
) (*events.CognitoEventUserPoolsDefineAuthChallenge, error) {
	status := calculateChallengeStatus(event.Request.Session)
	challengeName := ""
	done := status.Passed && status.Challenge.Attr != auth.NoVerification
	if done {
	  // ...
	} else {
		challengeName = "CUSTOM_CHALLENGE"
	}
	event.Response = events.CognitoEventUserPoolsDefineAuthChallengeResponse{
		IssueTokens:        done,
		FailAuthentication: status.AttemptCount >= 4,
		ChallengeName:      challengeName,
	}
	return event, nil
}
```

The state machine keeps turning until we reach the maximum allowed failed attempts or the user provides us with the correct OTP. We tick off two requirements 🎉

- [x] The user must be able to sign-in with their email or phone number
- [ ] There is not separate sign-up process, as it should follow directly from the sign-in
- [ ] We mustn't leak user existence errors through the public API, including Cognito's
- [x] OTPs are valid only for a brief period of time
- [x] Users have more than one attempt to input the correct OTP

Unfortunately, this isn't sufficient to support our authentication flow. Recall that we have no separate sign-up process, so we might not have a registered user for a run of this protocol. However, we need the attribute to verify to be present in the user attributes, which are only populated for existing users (also, Cognito doesn't issue tokens for users absent from the pool). We must ensure a user exists, even if a partial one.

## Sign-In

Users aren't allowed to sign themselves up (to prevent using the `SignUp` API as a user-existence oracle), so we need something that does that on their behalf, say, a Lambda resolver for a GraphQL API on AppSync. 

We can call the `AdminCreateUser` API to ensure the user exists, return to the client and let it handle the rest of the flow, but we'd be optimizing for the less common scenario of a user not existing in the pool. 

Instead, we try to `InititateAuth` and if that fails, we issue the `AdminCreateUser` request. Does this mean we need to mediate all requests between the client and Cognito? No! We can take the session object returned by Cognito and return it to the client to be used in the `RespondToAuthChallenge` call.

Since we're already here though, we can even go ahead and execute the first step of the protocol, telling Cognito which attribute we want to verify:

```go
func handler(ctx context.Context, input SignInInput) (*SignInPayload, error) {
  session, op := auth.InitiateAuth(cognito, input.EmailOrPhone, input.Method)
	if op.Error == nil {
		return &SignInPayload{string(session)}, op
	}
	op = auth.CreateUser(cognito, input.EmailOrPhone, input.Method)
	if op.Error != nil {
		return nil, op.Error
	}
  session, op = auth.InitiateAuth(cognito, input.EmailOrPhone, input.Method)
	return &SignInPayload{string(session)}, op.Error
}
```

The `CreateUser` function calls the `AdminCreateUser` API, generating a random username and password and marking the email or phone number attribute as verified so it can be used as an alias. [^3] The `InitiateAuth` function calls Cognito's own `InitiateAuth` and then the first `RespondToAuthChallenge`. The rest is up to the client.

We encapsulate the `AdminCreateUser` API and behave the same regardless of the user existing before the request or not. Yep, another one down:

- [x] The user must be able to sign-in with their email or phone number
- [ ] There is not separate sign-up process, as it should follow directly from the sign-in
- [x] We mustn't leak user existence errors through the public API, including Cognito's
- [x] OTPs are valid only for a brief period of time
- [x] Users have more than one attempt to input the correct OTP

[^3]: It also calls the `AdminSetUserPassword` API to move the user from the `FORCE_CHANGE_PASSWORD` state to `CONFIRMED`.

## Sign-Up

Uff. The user is signed-in. However, there's a chance the JWT token our client got back from Cognito contains only a single claim for either the user's email or phone number, and we need more! In that case, our UI proceeds to collect some user data: their name and the second authentication method.

We have another attribute to verify, so we need to run another instance of our protocol. Lets add a GraphQL resolver that writes the user data into Cognito by updating the user attributes.[^4]

```go
func handler(ctx context.Context, input SignUpInput) (SignUpPayload, error) {
	auth.SetAttributes(cognito, input.Username, map[string]string{
		input.Data.Method.String(): input.Data.EmailOrPhone,
		fmt.Sprintf("%s_verified", input.Data.Method.String()): "false",
		"given_name":  input.Data.GivenName,
		"family_name": input.Data.FamilyName,
	}, auth.UserPoolID()))
  session, op := auth.InitiateAuth(cognito, input.Username, input.Data.Method)
	return SignUpPayload{string(session)}, op.Error
}
```

Note that we mark that second method as not verified, because Cognito only allows one user to have a given email or phone number marked as verified and thus use it as an alias [^5]. We want to avoid the following scenario:

1. Alice uses the verified email E;
2. Bob signs-in using their phone number, and then signs-up with E;
3. Bob doesn't verify E, but we already marked the attribute as verified;
4. Alice signs-in using E, sees Bob's account.

Our system should consider a user as valid only when it has both attributes verified, but who marks the second method as such? That's a job for our _define auth challenge_ Lambda:

```go
if done {
  attr := fmt.Sprintf("%s_verified", string(status.Attr))
	if event.Request.UserAttributes[attr] == "false" {
			auth.SetAttributes(cognito, event.UserName, map[string]string{
		    attr: "true",
	    })
	}
} // ...
```

Finally, note that this very same logic can be used to implement an API for changing a user's e-mail or phone number.

[^4]: This resolver is using Cognito authorization, so only signed-in users can use it. The `Username` points to the user making the request.

[^5]: Cognito understands the `email_verified` and `phone_number_verified` attributes.

## Refreshing the token

We're almost done, but there's one missing detail: Cognito isn't prepared for us modifying a user's attributes during the authentication process, so the token the client ends up with after the sign-up doesn't have the second attribute marked as verified. There's no clever solution here: we must call the `InitiateAuth` API using the `REFRESH_TOKEN_AUTH` flow instead of `CUSTOM_AUTH`.

We got them all.

- [x] The user must be able to sign-in with their email or phone number
- [x] There is not separate sign-up process, as it should follow directly from the sign-in
- [x] We mustn't leak user existence errors through the public API, including Cognito's
- [x] OTPs are valid only for a brief period of time
- [x] Users have more than one attempt to input the correct OTP

## Conclusion

Cognito isn't easy to work with and I find the client libraries and documentation to be somewhat lacking, but at the end of the say it's solving a huge problem on our behalf. It has enough flexibility to be subject to some unorthodox usage, and I hope it keeps on improving.

For this particular use case I missed some additional toggles, which could be added without loss of generality: it could flow the client metadata from `InitiateAuth` to the authentication Lambdas, and it could allow the _define auth challenge_ Lambda to override some token claims, much like the _pre-token generation_ Lambda can. Another easy improvement is allowing the custom auth Lambdas to return errors, which should be returned from the API calls (even if as some generic error). By contacting AWS support I was able to turn the first of these in a feature request, but of course, without any estimate when it will be available.

Thanks to [@_jwnx](https://twitter.com/_jwnx) for reviewing this post.