Basic Authentication with AWS Cognito & NextJS

Basic Authentication with AWS Cognito & NextJS

I’ve written quite a few articles about authentication before. This is yet another user auth article.

However, this time it’s a little different. Previous articles have been about managing user authentication yourself. In this article, we will be leveraging AWS Cognito and its user pools for the same functionality.

You can check out some of my previous articles on handling user auth manually here:

  1. How to Create Registration & Authentication with Express & PassportJS.
  2. How to Handle Password Reset in ExpressJS.
  3. How to Verify Users in ExpressJS

Setup

This article assumes that you have an AWS Cognito user pool set up and that you have some NextJS boilerplate code set up as well. I will write another article explaining how to set up a Cognito user pool using the AWS console.

First, let’s understand the project structure.

.
├── components
│   ├── layouts
│   │   └── InputLayout.js
│   ├── AuthLinkText.js
│   ├── InputField.js
│   ├── InputHelperText.js
│   ├── Label.js
│   └── SubmitButton.js
├── hooks
│   ├── useAuth.js
│   ├── useRegister.js
│   └── useValidationSchema.js
├── pages
│   ├── api
│   │   ├── confirm
│   │   │   ├── index.js
│   │   │   └── send.js
│   │   ├── password
│   │   │   ├── reset.js
│   │   │   └── reset\_code.js
│   │   ├── login.js
│   │   └── register.js
│   ├── password
│   │   ├── reset.js
│   │   └── reset\_code.js
│   ├── \_app.js
│   ├── confirm.js
│   ├── index.js
│   ├── login.js
│   └── register.js
├── public
│   ├── favicon.ico
│   └── vercel.svg
├── styles
│   ├── Home.module.css
│   └── globals.css
├── README.md
├── next.config.js
├── package-lock.json
└── package.json

Most of these are nothing special as they’re created automatically when you set up a NextJS project.

The “components” folder contains all the re-usable custom components like form input fields and layouts.

For this article, I will focus on the “hooks” and “pages” folders. If you’d like to look at the full code, you can find it on Github.

The “hooks” folder has 3 files:

  1. useAuth.js contains a hook that will handle user sign in and password reset.
  2. useRegister.js contains a hook that will handle user registration and verification.
  3. useValidationSchema.js contains the form validation schemas. We won’t be covering this in the article. Feel free to check the repo out for more details on this.

I prefer to use hooks because they allow me to separate the business logic from the UI logic in the component.

The “pages” folder has an “API” sub-directory. This is where all the backend code lives.

NextJS uses file-system based routing so the file structure is what determines the API endpoints.

All the other files and sub-directories outside the API sub-directory will be treated as frontend pages.

Make sure to install the @aws-sdk/client-cognito-identity-provider package.

You can refer to the full CognitoIdentityServiceProvider SDK for more in-depth explanations of what is discussed in this article.

Sign Up

First, let’s handle user registration. Navigate to the “pages/api/register.js” and add the following code:

import { CognitoIdentityProviderClient, SignUpCommand } from '@aws-sdk/client-cognito-identity-provider'

const { COGNITO_REGION, COGNITO_APP_CLIENT_ID } = process.env

export default async function handler(req, res) {
    if (req.method !== 'POST') return res.status(405).send()

    const params = {
        ClientId: COGNITO_APP_CLIENT_ID,
        Password: req.body.password,
        Username: req.body.username,
        UserAttributes: [
            {
                Name: 'email',
                Value: req.body.email
            }
        ]
    }

    const cognitoClient = new CognitoIdentityProviderClient({
        region: COGNITO\_REGION
    })
    const signUpCommand = new SignUpCommand(params)

    try {
        const response = await cognitoClient.send(signUpCommand)
        return res.status(response['$metadata'].httpStatusCode).send()
    } catch (err) {
        console.log(err)
        return res.status(err['$metadata'].httpStatusCode).json({ message: err.toString() })
    }
}

This is a handler for the “/api/register” endpoint. Include a guard clause to make sure only POST requests are allowed, return a 405 error for any other type of request.

In the params, we have the following:

  1. ClientId – App client Id that you created for this user pool in the AWS console.
  2. Password – The user’s chosen password.
  3. Username -The user’s chosen username.
  4. UserAttributes – Any additional attributes provided by the user upon signing up. The default list of attributes includes address, nickname, birthdate, phone number, email, family name, preferred username, gender, profile, given name, zoneinfo, locale, updated at, middle name, website and name. You can add custom attributes as well (for example, “Department” if you’re creating an auth system for an organisation).

Keep in mind that if you’re setting a custom user attribute you need to follow the following format:

{
    Name: 'custom:<AttributeName>',                
    Value: '<AttributeValue>'
}

In the case of adding a department attribute, it would look like this:

{
    Name: 'custom:Department',                
    Value: 'Engineering'
}

Then we send the SignUpCommand to trigger a user sign up. Return a status 200 response if all goes well, otherwise, return the error code from Cognito and the stringified version of the error.

Cognito will usually return an error that looks like this:

UsernameExistsException: User already exists
...
{
  '$fault': 'client',
  '$metadata': {
    httpStatusCode: 400,
    requestId: '9442223e-ef29-40c1-880e-6689638d8042',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  __type: 'UsernameExistsException'
}

Grab the status code and message and then forward them to the front end.

When the request is successful, Cognito will return a response that looks like this:

{
  '$metadata': {
    httpStatusCode: 200,
    requestId: '67f6d99c-d13c-4677-ab46-957d88f62bb9',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  AuthenticationResult: {
    AccessToken: "...",
    ExpiresIn: 3600,
    NewDeviceMetadata: undefined,
    RefreshToken: "...",
    TokenType: "Bearer"
  },
  ChallengeName: undefined,
  ChallengeParameters: {},
  Session: undefined
}

For this application, we’re only interested in the Authentication result as it contains the access and refresh tokens.

Flesh out the useRegister hook in order to make use of the API endpoint we created above.

Create a method called “register” with the following logic:

import { useRouter } from 'next/router'

export default function useRegister() {

    const router = useRouter()

    const register = (values, { setSubmitting }) => {
        fetch('/api/register', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(values)
        }).then(res => {
            if (!res.ok) throw res
            router.push({
                pathname: '/confirm',
                query: { username: values?.username }
            },
                "/confirm")
        }).catch(err => {
            console.error(err)
        }).finally(() => {
            setSubmitting(false)
        })
    }

    const confirm = (values, { setSubmitting }) => {
        // Confirm the user
    }

    return {
        register,
        confirm
    }
}

The register method is the submit method for our registration form. The “values” object contains the form data and “setSubmitting” allows us to update the loading state once we’re done processing the request.

This will be a common theme with most of the hooks in this project.

Set the Content-Type header to “application/json” to help NextJS parse the request body.

In this method, we call the register endpoint and pass all the form values (which include username, email, and password). If the request is successful, we redirect to the confirm page and prompt the user to verify their email address. More on this in the verification section.

You might notice we have an empty confirm method here. This method will handle the request to verify the user. We will cover this in the verification stage as well.

Here is what the registration form will look like, notice we import the useRegister hook and pass the register method as the form submit handler.

import { Formik } from "formik";
import InputLayout from "../components/layouts/InputLayout";
import Label from "../components/Label";
import InputField from "../components/InputField";
import InputHelperText from "../components/InputHelperText";
import AuthLinkText from "../components/AuthLinkText";
import SubmitButton from "../components/SubmitButton";
import useValidationSchema from "../hooks/useValidationSchema";
import useRegister from '../hooks/useRegister'
import Link from "next/link";

export default function Register() {

    const { registerSchema } = useValidationSchema()
    const { register } = useRegister()

    return (
        <div style={{
            padding: "10px"
        }}>
            <Formik
                initialValues={{
                    username: "",
                    email: "",
                    password: "",
                    confirm_password: ""
                }}
                validationSchema={registerSchema}
                onSubmit={register}
                validateOnMount={false}
                validateOnChange={false}
                validateOnBlur={false}>
                {({
                    isSubmitting,
                    errors,
                    values,
                    handleSubmit,
                    handleChange,
                    handleBlur
                }) => (
                    <form onSubmit={handleSubmit}>
                        <InputLayout>
                            <Label>Username</Label>
                            <InputField
                                type="text"
                                name="username"
                                placeholder="Username"
                                onChange={handleChange}
                                onBlur={handleBlur}
                                value={values?.username}
                            />
                            <InputHelperText isError>{errors?.username}</InputHelperText>
                        </InputLayout>
                        <InputLayout>
                            <Label>Email</Label>
                            <InputField
                                type="email"
                                name="email"
                                placeholder="Email"
                                onChange={handleChange}
                                onBlur={handleBlur}
                                value={values?.email}
                            />
                            <InputHelperText isError>{errors?.email}</InputHelperText>
                        </InputLayout>
                        <InputLayout>
                            <Label>Password</Label>
                            <InputField
                                type="password"
                                name="password"
                                placeholder="Password"
                                onChange={handleChange}
                                onBlur={handleBlur}
                                value={values?.password}
                            />
                            <InputHelperText isError>{errors?.password}</InputHelperText>
                        </InputLayout>
                        <InputLayout>
                            <Label>Confirm password</Label>
                            <InputField
                                type="password"
                                name="confirm_password"
                                placeholder="Confirm password"
                                onChange={handleChange}
                                onBlur={handleBlur}
                                value={values?.confirm_password}
                            />
                            <InputHelperText isError>{errors?.confirm_password}</InputHelperText>
                        </InputLayout>
                        <InputLayout>
                            <AuthLinkText href="/login">Already have an account? Log in</AuthLinkText>
                        </InputLayout>
                        <SubmitButton isSubmitting={isSubmitting} />
                    </form>
                )}
            </Formik>
        </div>
    )
}

Sign In

Now that we’ve registered our users, it’s time to allow them to log into our application by authenticating them against our Cognito user pool.

Add the following code to the “pages/api/login.js” file.

import { CognitoIdentityProviderClient, AdminInitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider"

const { COGNITO_REGION, COGNITO_APP_CLIENT_ID, COGNITO_USER_POOL_ID } = process.env

export default async function handler(req, res) {
    if (req.method !== 'POST') return res.status(405).send()

    const params = {
        AuthFlow: 'ADMIN_USER_PASSWORD_AUTH',
        ClientId: COGNITO_APP_CLIENT_ID,
        UserPoolId: COGNITO_USER_POOL_ID,
        AuthParameters: {
            USERNAME: req.body.username,
            PASSWORD: req.body.password
        }
    }

    const cognitoClient = new CognitoIdentityProviderClient({
        region: COGNITO_REGION
    })
    const adminInitiateAuthCommand = new AdminInitiateAuthCommand(params)

    try {
        const response = await cognitoClient.send(adminInitiateAuthCommand)
        console.log(response)
        return res.status(response['$metadata'].httpStatusCode).json({
            ...response.AuthenticationResult
        })
    } catch(err) {
        console.log(err)
        return res.status(err['$metadata'].httpStatusCode).json({ message: err.toString() })
    }
}

At the moment, we’re handling only username/password auth. To do this we need to use the ADMIN_USER_PASSWORD_AUTH flow. This authentication flow requires developer credentials. If you're authenticating users in a secure server, this is the recommended method over InitiateAuthCommand with USER_PASSWORD_AUTH.

The final parameter is AuthParameters. This simply contains the username and password passed to the endpoint upon form submission. Be careful here and make sure the auth parameters are in all uppercase as you see here, otherwise, they will not be recognised.

Now let’s divert our attention to the “useAuth” hook. Add this code to the “hooks/useAuth.js” file:

import { useRouter } from "next/router";

export default function useAuth(){

  const router = useRouter()

  const login = (values, { setSubmitting }) => {
    fetch('/api/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(values)
    }).then(res => {
      if (!res.ok) throw res
    }).then(data => {
      console.log(data)
    }).catch(async err => {
      const responseData = await err.json()
      if (responseData?.message?.includes("UserNotConfirmedException:")) {
        // Trigger confirmation code email
        await fetch('/api/confirm/send', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ username: values.username })
        })
        await router.push(
      {
            pathname: "/confirm",
            query: {username: values.username},
          },
          "/confirm")
      }
    }).finally(() => {
      setSubmitting(false)
    })
  }

  const resetPasswordRequest = (values, { setSubmitting }) => {
    // Send the password reset code
  }

  const resetPassword = (values, { setSubmitting }) => {
    // Send request to reset password
  }

  return {
    login,
    resetPasswordRequest,
    resetPassword
  }
}

Here we have a login method that is similar to the register method we created above. The “values” object represents our form data (username and password).

It’s important to note that Cognito will not allow a user to be authenticated if they have not yet been verified/confirmed. So attempting this login with an unconfirmed user will return an error.

To handle this, trigger an endpoint to send a confirmation email to the user and then redirect them to the confirm page so they can be verified. More on this endpoint in the verification section.

Our login page has the following code:

import { Formik } from "formik";
import InputLayout from "../components/layouts/InputLayout";
import Label from "../components/Label";
import InputField from "../components/InputField";
import InputHelperText from "../components/InputHelperText";
import AuthLinkText from "../components/AuthLinkText";
import SubmitButton from "../components/SubmitButton";
import useAuth from "../hooks/useAuth";
import useValidationSchema from "../hooks/useValidationSchema";
import { useRouter } from "next/router";

export default function Login() {

  const router = useRouter();
  const { success } = router.query;

  const { loginSchema } = useValidationSchema();
  const { login } = useAuth();

  return (
    <div style={{
      padding: "10px"
    }}>
      {
        success === "true" &&
        <div style={{
          paddingTop: "10px",
          paddingBottom: "10px",
          color: "green"
        }}>
          {'You\\'re signed up!'}
        </div>
      }
      <Formik
        initialValues={{
          username: "",
          password: ""
        }}
        validationSchema={loginSchema}
        onSubmit={login}
        validateOnMount={false}
        validateOnChange={false}
        validateOnBlur={false}>
        {({
          isSubmitting,
          errors,
          values,
          handleChange,
          handleBlur,
          handleSubmit
        }) => (
          <form onSubmit={handleSubmit}>
            <InputLayout>
              <Label>Username</Label>
              <InputField
                type="text"
                name="username"
                placeholder="Username or email"
                onChange={handleChange}
                onBlur={handleBlur}
                value={values?.username}
              />
              <InputHelperText isError>{errors?.username}</InputHelperText>
            </InputLayout>
            <InputLayout>
              <Label>Password</Label>
              <InputField
                type="password"
                name="password"
                placeholder="Password"
                onChange={handleChange}
                onBlur={handleBlur}
                value={values?.password}
              />
              <InputHelperText isError>{errors?.password}</InputHelperText>
            </InputLayout>
            <InputLayout>
              <AuthLinkText href="/password/reset_code">{'Forgot password?'}</AuthLinkText>
            </InputLayout>
            <InputLayout>
              <AuthLinkText href="/register">{'Don\\'t have an account? Register.'}</AuthLinkText>
            </InputLayout>
            <SubmitButton isSubmitting={isSubmitting} />
          </form>
        )}
      </Formik>
    </div>
  );
}

Just like before, we import the useAuth hook and make use of its login method as the form submission handler.

We will discuss the 2 password reset methods in the useAuth hook in the password reset section.

Verification

So we’ve registered our users, but we cannot authenticate them because they are not verified. Let’s fix that.

A few things to note before we continue:

  1. I have set up my user pool to use an email code for verification. This is where AWS will send an email with a code that the user has to enter manually on your app. Other authentication methods include: a clickable verification link and a verification code sent to their phone number. If you are using any of the other verification methods (except verification code to a phone number), then you can go ahead and skip this section.
  2. Cognito will automatically trigger the chosen verification method upon successful registration. Verification can also be triggered via the SDK.

In the “pages/api/confirm/index.js” file, add the following code:

import {
    CognitoIdentityProviderClient,
    ConfirmSignUpCommand
} from "@aws-sdk/client-cognito-identity-provider"

const { COGNITO_REGION, COGNITO_APP_CLIENT_ID } = process.env

export default async function handler (req, res) {
    if (req.method !== 'POST') return res.status(405).send()

    const params = {
        ClientId: COGNITO_APP_CLIENT_ID,
        ConfirmationCode: req.body.code,
        Username: req.body.username
    }

    const cognitoClient = new CognitoIdentityProviderClient({
        region: COGNITO_REGION
    })
    const confirmSignUpCommand = new ConfirmSignUpCommand(params)

    try {
        const response = await cognitoClient.send(confirmSignUpCommand)
        console.log(response)
        return res.status(response['$metadata'].httpStatusCode).send()
    } catch (err) {
        console.log(err)
        return res.status(err['$metadata'].httpStatusCode).json({ message: err.toString() })
    }
}

Here create and send confirmSignUpCommand with the following parameters:

  1. ClientId.
  2. ConfirmationCode – The code sent to the user’s email/phone by Cognito.
  3. Username – The username of the user you’d like to verify.

Note that because this is the index file within the confirm directory, we do not need to specify the filename when calling this endpoint. The endpoint would just be “/api/confirm”

In the same confirm directory, we have a file called “send.js”. This file is responsible for manually triggering the confirmation code email.

Add the following code to this file:

import {
    CognitoIdentityProviderClient,
    ResendConfirmationCodeCommand
} from "@aws-sdk/client-cognito-identity-provider"

const { COGNITO_REGION, COGNITO_APP_CLIENT_ID } = process.env

export default async function handler (req, res) {
    if (req.method !== 'POST') return res.status(405).send()

    const params = {
        ClientId: COGNITO_APP_CLIENT_ID,
        Username: req.body.username
    }

    const cognitoClient = new CognitoIdentityProviderClient({
        region: COGNITO_REGION
    })
    const resendConfirmationCodeCommand = new ResendConfirmationCodeCommand(params)

    try {
        const response = await cognitoClient.send(resendConfirmationCodeCommand)
        console.log(response)
        return res.status(response['$metadata'].httpStatusCode).send()
    } catch (err) {
        console.log(err)
        return res.stat(err['$metadata'].httpStatusCode).json({ message: err.toString() })
    }
}

Here, simply create and send ResendConfirmationCodeCommand with a params object containing the ClientId and the username of the user you’d like to verify.

Cognito will search for the user with the specified username, then send a verification code to their email/phone number depending on your settings and/or which one is provided.

You might be wondering why this command is called ResendConfirmationCodeCommand.

Cognito will automatically send a verification code to the user upon signing up.

If you are triggering the registration manually, then you are always “re-sending” the code.

Remember that “confirm” method in the useRegister hook? It’s time to flesh it out.

Add the following logic in that method:

const confirm = (values, { setSubmitting }) => {
        fetch('/api/confirm', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(values)
        }).then(res => {
            if (!res.ok) throw res
            router.push({
                pathname: '/login',
                query: { confirmed: true }
            },
                "/login")
        }).catch(err => {
            console.error(err)
        }).finally(() => {
            setSubmitting(false)
        })
    }

From this method, call the “/api/confirm” endpoint with the form values. If the confirmation is successful, redirect the user to the login page so they can sign in.

Here’s a look at the confirm page in the “pages/confirm.js” file:

import { Formik } from "formik";
import InputLayout from "../components/layouts/InputLayout";
import Label from "../components/Label";
import InputField from "../components/InputField";
import InputHelperText from "../components/InputHelperText";
import SubmitButton from "../components/SubmitButton";
import useValidationSchema from "../hooks/useValidationSchema";
import useRegister from "../hooks/useRegister";
import { useRouter } from "next/router";

export default function Confirm(){

    const router = useRouter();
    const { username } = router.query;

    const { confirm } = useRegister();
    const { confirmSchema } = useValidationSchema();

    return (
        <div style={{
            padding: "10px"
        }}>
            <Formik
                initialValues={{
                    username: username,
                    code: ""
                }}
                onSubmit={confirm}
                validationSchema={confirmSchema}
                validateOnMount={false}
                validateOnChange={false}
                validateOnBlur={false}>
                {
                    ({
                        isSubmitting,
                        errors,
                        values,
                        handleSubmit,
                        handleChange,
                        handleBlur
                     }) => (
                        <form onSubmit={handleSubmit}>
                            <InputLayout>
                                <Label>Confirmation Code</Label>
                                <InputField
                                    type={"text"}
                                    name={"code"}
                                    placeholder={"Code"}
                                    onChange={handleChange}
                                    onBlur={handleBlur}
                                    value={values?.code}
                                />
                                <InputHelperText isError>{errors?.code}</InputHelperText>
                            </InputLayout>
                            <SubmitButton isSubmitting={isSubmitting} />
                        </form>
                    )
                }
            </Formik>
        </div>
    )
}

Password reset

One of the most important features in any auth system is the ability to reset passwords.

Fortunately, password reset is made dead simple by Cognito.

The password reset flow is similar to the verification flow but with some extra steps:

  1. The user clicks the “Forgot password” link and is redirected to a page where they are prompted to enter their username.
  2. Send a confirmation code to the user’s email/number.
  3. If the code is sent successfully, redirect the user to a reset page where they enter the code, along with their new password.

First, prepare the endpoint to trigger the verification email containing the code.

Add the following code to “pages/api/password/reset_code.js”:

import { CognitoIdentityProviderClient, ForgotPasswordCommand } from '@aws-sdk/client-cognito-identity-provider'

const { COGNITO_REGION, COGNITO_APP_CLIENT_ID } = process.env

export default async function handler (req, res) {
    if (req.method !== 'POST') return res.status(405).send()

    const params = {
        ClientId: COGNITO_APP_CLIENT_ID,
        Username: req.body.username
    }

    const cognitoClient = new CognitoIdentityProviderClient({
        region: COGNITO_REGION
    })
    const forgotPasswordCommand = new ForgotPasswordCommand(params)

    try {
        const response = await cognitoClient.send(forgotPasswordCommand)
        console.log(response)
        return res.status(response['$metadata'].httpStatusCode).send()
    } catch (err) {
        console.log(err)
        return res.status(err['$metadata'].httpStatusCode).json({ message: toString() })
    }
}

Here we accept the ClientId and username params for the “ForgotPasswordCommand” command. Cognito will find a user with the matching username and send them a verification code using the configured method.

In the “pages/api/password/reset.js” file, add the code below:

import {
    CognitoIdentityProviderClient,
    ConfirmForgotPasswordCommand
} from "@aws-sdk/client-cognito-identity-provider"

const { COGNITO_REGION, COGNITO_APP_CLIENT_ID } = process.env

export default async function handler(req, res) {
    if (req.method !== 'POST') return res.status(405).send()

    const params = {
        ClientId: COGNITO_APP_CLIENT_ID,
        ConfirmationCode: req.body.code,
        Password: req.body.password,
        Username: req.body.username
    }

    const cognitoClient = new CognitoIdentityProviderClient({
        region: COGNITO_REGION
    })
    const confirmForgotPasswordCommand = new ConfirmForgotPasswordCommand(params)

    try {
        const response = await cognitoClient.send(confirmForgotPasswordCommand)
        console.log(response)
        return res.status(response['$metadata'].httpStatusCode).send()
    } catch (err) {
        console.log(err)
        return res.status(err['$metadata'].httpStatusCode).json({ message: err.toString() })
    }
}

Here we accept the ConfirmationCode that was sent to the user, their new password and their username.

Cognito will take care of all the logic of making sure that the code provided is valid and is the one that was sent to the user with the specified username.

You’ll remember that in the useAuth hook, we had 2 empty methods “resetPasswordRequest” and “resetPassword”. We are going to flesh those out now.

Add the following logic to those methods:

const resetPasswordRequest = (values, { setSubmitting }) => {
    fetch('/api/password/reset\_code', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(values)
    }).then(res => {
      if (!res.ok) throw res
      router.push({
        pathname: '/password/reset',
        query: { username: values.username }
      },
        "/password/reset")
    }).catch(err => {
      console.error(err)
    }).finally(() => {
      setSubmitting(false)
    })
  }

  const resetPassword = (values, { setSubmitting }) => {
    fetch('/api/password/reset', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(values)
    }).then(res => {
      if (!res.ok) throw res
      router.push({
        pathname: '/login',
        query: { reset: true }
      },
        "/login")
    }).catch(err => {
      console.error(err)
    }).finally(() => {
      setSubmitting(false)
    })
  }

The resetPasswordRequest method triggers the “/api/password/reset_code” endpoint.

From the user’s perspective, they are redirected to a page where they enter their username. Once they submit the form, they receive an email and are redirected to the password reset page (if the email was successfully sent).

The resetPassword method triggers the “/api/password/reset” endpoint to actually reset the password.

Here’s how the forgot password form looks:

import { Formik } from "formik";
import InputLayout from "../../components/layouts/InputLayout";
import Label from "../../components/Label";
import InputField from "../../components/InputField";
import InputHelperText from "../../components/InputHelperText";
import SubmitButton from "../../components/SubmitButton";
import useValidationSchema from "../../hooks/useValidationSchema";
import useAuth from '../../hooks/useAuth';

export default function ResetCode(){

    const { resetPasswordRequestSchema } = useValidationSchema();
    const { resetPasswordRequest } = useAuth();

    return (
        <div style={{
            padding: "10px"
        }}>
            <Formik
                initialValues={{
                    username: ""
                }}
                onSubmit={resetPasswordRequest}
                validationSchema={resetPasswordRequestSchema}
                validateOnMount={false}
                validateOnChange={false}
                validateOnBlur={false}
                >
                {
                    ({
                        isSubmitting,
                        errors,
                        values,
                        handleSubmit,
                        handleChange,
                        handleBlur
                    }) => (
                        <form onSubmit={handleSubmit}>
                            <InputLayout>
                                <Label>Username</Label>
                                <InputField
                                    type={"text"}
                                    name={"username"}
                                    placeholder={"Username"}
                                    onChange={handleChange}
                                    onBlur={handleBlur}
                                    value={values?.username}
                                />
                                <InputHelperText isError>{errors?.username}</InputHelperText>
                            </InputLayout>
                            <SubmitButton isSubmitting={isSubmitting} />
                        </form>
                    )
                }
            </Formik>
        </div>
    )
}

Here’s how the password reset form looks:

import { Formik } from "formik";
import InputLayout from "../../components/layouts/InputLayout";
import Label from "../../components/Label";
import InputField from "../../components/InputField";
import InputHelperText from "../../components/InputHelperText";
import SubmitButton from "../../components/SubmitButton";
import useValidationSchema from "../../hooks/useValidationSchema";
import useAuth from '../../hooks/useAuth';
import { useRouter } from "next/router";

export default function Reset(){

    const router = useRouter()
    const { username } = router.query

    const { resetPasswordSchema } = useValidationSchema();
    const { resetPassword } = useAuth()

    return (
        <div style={{
            padding: "10px"
        }}>
            <Formik
                initialValues={{
                    username: username,
                    code: "",
                    password: "",
                    confirm\_password: ""
                }}
                validationSchema={resetPasswordSchema}
                onSubmit={resetPassword}
                validateOnMount={false}
                validateOnChange={false}
                validateOnBlur={false}
            >
                {
                    ({
                        isSubmitting,
                        errors,
                        values,
                        handleSubmit,
                        handleBlur,
                        handleChange
                    }) => (
                        <form onSubmit={handleSubmit}>
                            <InputLayout>
                                <Label>Reset code</Label>
                                <InputField
                                    type={"text"}
                                    name={"code"}
                                    placeholder={"Reset code"}
                                    onChange={handleChange}
                                    onBlur={handleBlur}
                                    value={values?.code}
                                />
                                <InputHelperText isError>{errors?.code}</InputHelperText>
                            </InputLayout>
                            <InputLayout>
                                <Label>New password</Label>
                                <InputField
                                    type={"password"}
                                    name={"password"}
                                    placeholder={"New password"}
                                    onChange={handleChange}
                                    onBlur={handleBlur}
                                    value={values?.password}
                                />
                                <InputHelperText isError>{errors?.password}</InputHelperText>
                            </InputLayout>
                            <InputLayout>
                                <Label>Confirm password</Label>
                                <InputField
                                    type={"password"}
                                    name={"confirm_password"}
                                    placeholder={"Confirm password"}
                                    onChange={handleChange}
                                    onBlur={handleBlur}
                                    value={values?.confirm_password}
                                />
                                <InputHelperText isError>{errors?.confirm_password}</InputHelperText>
                            </InputLayout>
                            <SubmitButton isSubmitting={isSubmitting} />
                        </form>
                    )
                }
            </Formik>
        </div>
    )
}

Caveat

A major caveat with the implementation we’ve just created is that 2 or more users can actually register with the same email.

We don’t want any user to receive a code for another user’s password reset.

For this and many other reasons, you may want to force unique emails for each user.

We can easily achieve this using Cognito triggers which are event triggers that run custom lambda functions. These allow us to customise our workflows.

We can trigger lambdas during any of the following events (and more):

  1. Pre-signup – Right before Cognito signup is triggered.
  2. Pre-authentication – Right before the user is authenticated by Cognito.
  3. Custom message – Right before a verification/confirmation message is sent. We can dynamically edit the message here.
  4. Post-authentication – After the user is successfully authenticated. If the authentication fails, this will not be triggered.
  5. Post-confirmation – After a user is verified/confirmed. You can use this to send a welcome email or enable certain privileges that are only available to confirmed users.

These are only a few of the triggers available to us. You can find more information on these triggers here.

In our case, the trigger we’re most interested in is the Pre-signup trigger. We can check if there are any current users who have the same email address as the one we’ve just received for signup. If so, throw an error and fail early before the Cognito signup.

I will publish an article that demonstrates how to achieve this.