Skip to content

AWS Cognito User Pools: Add Auth to a React App

Sign in page
Pretty picture of the Login UI.

Cognito User Pools is an AWS service that provides applications with user identity and auth. This series of articles cover a full stack solution that enables users to sign in with their Email + Password, Google Sign In, or SSO (SAML), and link all methods to the same user within the app:

  1. Create Resources with CDK
  2. Sign in with Email, Google, or SAML and link to a single user
  3. Add Auth to a React App ๐Ÿ‘ˆ You are here

This article focuses on integrating Cognito User Pools with a React (Next.js) Web App, though the fundamentals remain the same for all frameworks. The example also uses the Ant Design UI Framework to make everything look good.

React Cognito Auth

To add Cognito Auth to our React web app (specifically Next.js in this case) weโ€™ll use the Amplify JavaScript Library, which simplifies the complex OAuth flow.

The work required boils down to:

  1. Configuring the Amplify library with details about our Cognito User Pool
  2. Configuring Axios with the base URL of our API endpoint, and attaching the Authorizers header on each API request.
  3. Adding pages for Login, Register, Verify, Forgot Password, Reset Password. See Code Genie pages docs for more details.
  4. Wrapping each of the Amplify methods we use with TanStack Query useQuery hooks for improved state management. See Code Genie hooks docs for more details.

_app.tsx

packages/ui/pages/_app.tsx
import { Amplify } from 'aws-amplify'
import { fetchAuthSession } from 'aws-amplify/auth'
import axios from 'axios'
const oauthRedirectUrl = ['http://localhost:3001/']
if (global.window?.location.origin) oauthRedirectUrl.push(global.window.location.origin)
Amplify.configure({
Auth: {
Cognito: {
userPoolId: process.env.NEXT_PUBLIC_CognitoUserPoolId!,
userPoolClientId: process.env.NEXT_PUBLIC_CognitoUserPoolClientId!,
loginWith: {
oauth: {
redirectSignIn: oauthRedirectUrl,
redirectSignOut: oauthRedirectUrl,
// replace 'example' with the regionallyUniqueDomainPrefix value defined in CDK
domain: 'example.auth.us-west-2.amazoncognito.com',
responseType: 'code',
scopes: ['aws.cognito.signin.user.admin', 'email', 'openid', 'phone', 'profile'],
},
},
},
},
}, {
ssr: false,
})
axios.defaults.baseURL = process.env.NEXT_PUBLIC_ApiEndpoint
// Set Authorization header on all requests if user is signed in; othwerise, redirect to login page
axios.interceptors.request.use(async (config) => {
try {
const authSession = await fetchAuthSession()
config.headers.Authorization = authSession.tokens?.idToken?.toString()
} catch (e) {
const redirectRoute = getRedirectToLoginPageUrl()
global.window.location.href = redirectRoute
}
return config
})
...

Login Page

packages/ui/pages/index.tsx
import React, { useEffect, useRef } from 'react'
import {
Button,
Checkbox,
Divider,
Form,
Input,
InputRef,
Typography,
} from 'antd'
import Link from 'next/link'
import UnauthenticatedPage from '../components/layouts/UnauthenticatedPage'
import { useSignInMutation, useSignInWithGoogleMutation, useSignInWithSsoMutation } from '../components/Me/meHooks'
import { useRouter } from 'next/router'
import { GoogleOutlined, LockOutlined } from '@ant-design/icons'
export default function LoginPage() {
const [form] = Form.useForm()
const passwordInputRef = useRef<InputRef>(null)
const signInMutation = useSignInMutation()
const signInWithGoogleMutation = useSignInWithGoogleMutation()
const signInWithSsoMutation = useSignInWithSsoMutation()
const router = useRouter()
const queryParamEmail = (router.query.email as string) || ''
const isSigningIn = signInMutation.isLoading || signInWithGoogleMutation.isLoading || signInWithSsoMutation.isLoading
useEffect(() => {
if (queryParamEmail) {
form.setFieldsValue({
email: queryParamEmail,
})
passwordInputRef.current?.focus()
}
}, [queryParamEmail])
return (
<UnauthenticatedPage pageTitle='Login'>
<div style={{display: 'flex', justifyContent: 'space-between'}}>
<Button
icon={<GoogleOutlined />}
disabled={isSigningIn}
loading={signInWithGoogleMutation.isLoading}
type='primary'
onClick={() => signInWithGoogleMutation.mutate()}
>
Sign in with Google
</Button>
<Button
icon={<LockOutlined />}
disabled={isSigningIn}
loading={signInWithSsoMutation.isLoading}
onClick={() => signInWithSsoMutation.mutate()}
>
SSO
</Button>
</div>
<Divider><Typography.Text type='secondary' italic>or sign in with email</Typography.Text></Divider>
<Form
layout='vertical'
name='login_form'
initialValues={{ remember: true }}
onFinish={signInMutation.mutate}
form={form}
disabled={isSigningIn}
>
<Form.Item
label='Email'
name='email'
required={false}
rules={[
{
required: true,
message: 'Email is required.',
type: 'email',
},
]}
>
<Input type='email' />
</Form.Item>
<Form.Item
label='Password'
name='password'
required={false}
rules={[
{
required: true,
message: 'Password is required.',
},
]}
>
<Input.Password ref={passwordInputRef} />
</Form.Item>
<Form.Item>
<Form.Item name='remember' valuePropName='checked' noStyle>
<Checkbox>Remember me</Checkbox>
</Form.Item>
<Button
loading={signInMutation.isLoading}
style={{ float: 'right' }}
type='primary'
htmlType='submit'
>
Sign in
</Button>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Link href='/register'>Register</Link>
<Link href='/forgot-password'>Forgot your password?</Link>
</div>
</Form>
</UnauthenticatedPage>
)
}

Register Page

packages/ui/pages/register.tsx
import React from 'react'
import {
Button,
Form,
Input,
} from 'antd'
import Link from 'next/link'
import UnauthenticatedPage from '../components/layouts/UnauthenticatedPage'
import { useSignUpMutation } from '../components/Me/meHooks'
export default function App() {
const signUpMutation = useSignUpMutation()
return (
<UnauthenticatedPage pageTitle='Register'>
<Form
layout='vertical'
name='register_form'
onFinish={signUpMutation.mutate}
validateTrigger='onBlur'
>
<Form.Item
label='Name'
name='name'
required={false}
rules={[
{ required: true, message: 'Please enter your name.' },
]}
>
<Input />
</Form.Item>
<Form.Item
label='Email'
name='email'
required={false}
rules={[
{
required: true,
message: 'Please enter your email.',
type: 'email',
},
]}
>
<Input type='email' />
</Form.Item>
<Form.Item
style={{ marginBottom: '10px' }}
label='Password'
name='password'
required={false}
rules={[
{
required: true,
message: 'Please enter your password.',
},
]}
>
<Input.Password />
</Form.Item>
<Form.Item style={{ margin: '0' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button style={{ marginRight: '1rem' }}>
<Link href='/'>Back to login</Link>
</Button>
<Button
type='primary'
disabled={signUpMutation.isLoading}
loading={signUpMutation.isLoading}
htmlType='submit'
className='login-form-button'
>
Register
</Button>
</div>
</Form.Item>
</Form>
</UnauthenticatedPage>
)
}

Verify Page

packages/ui/pages/verify.tsx
import React, { useEffect } from 'react'
import {
Button,
Typography,
Form,
Input,
} from 'antd'
import { useRouter } from 'next/router'
import Link from 'next/link'
import UnauthenticatedPage from '../components/layouts/UnauthenticatedPage'
import { useVerifyAccountMutation } from '../components/Me/meHooks'
const { Title } = Typography
export default function VerifyPage() {
const [form] = Form.useForm()
const router = useRouter()
const verifyAccountMutation = useVerifyAccountMutation()
const queryParamEmail = (router.query.email as string) || ''
const queryParamCode = (router.query.code as string) || ''
useEffect(() => {
if (queryParamEmail && queryParamCode) {
verifyAccountMutation.mutate({ email: queryParamEmail, code: queryParamCode })
}
form.setFieldsValue({
email: queryParamEmail,
code: queryParamCode,
})
}, [queryParamEmail, queryParamCode])
return (
<UnauthenticatedPage pageTitle='Verify Account'>
<Title level={3}>Verify your account</Title>
{queryParamCode ? null : <p>Check your inbox for a verification email that includes a verification code, and enter it here. Alternatively, simply click the link in the email.</p>}
<Form
layout='vertical'
name='verifyAccountForm'
form={form}
onFinish={verifyAccountMutation.mutate}
>
<Form.Item
label='Email'
name='email'
required={false}
rules={[
{
required: true,
message: 'Email is required.',
type: 'email',
},
]}
>
<Input type='email' />
</Form.Item>
<Form.Item
label='Verification Code'
name='code'
required={false}
rules={[
{
required: true,
message: 'Code is required.',
max: 6,
},
]}
>
<Input />
</Form.Item>
<Form.Item style={{ margin: '0' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button style={{ marginRight: '1rem' }}>
<Link href='/'>Back to login</Link>
</Button>
<Button
type='primary'
disabled={verifyAccountMutation.isLoading}
loading={verifyAccountMutation.isLoading}
htmlType='submit'
className='verify-account-form-button'
>
Verify Account
</Button>
</div>
</Form.Item>
</Form>
</UnauthenticatedPage>
)
}

Forgot Password Page

packages/ui/pages/forgot-password.tsx
import React from 'react'
import {
Button,
Typography,
Form,
Input,
} from 'antd'
import Link from 'next/link'
import UnauthenticatedPage from '../components/layouts/UnauthenticatedPage'
import { useForgotPasswordMutation } from '../components/Me/meHooks'
const { Title } = Typography
export default function ForgotPassword() {
const forgotPasswordMutation = useForgotPasswordMutation()
return (
<UnauthenticatedPage pageTitle='Forgot Password'>
<Title level={3}>Forgot your password?</Title>
<Form
layout='vertical'
name='forgotPasswordForm'
onFinish={forgotPasswordMutation.mutate}
>
<Form.Item
label='Email'
name='email'
required={false}
rules={[
{
required: true,
message: 'Please enter your email.',
type: 'email',
},
]}
>
<Input type='email' />
</Form.Item>
<Form.Item style={{ margin: '0' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button style={{ marginRight: '1rem' }}>
<Link href='/'>Back to login</Link>
</Button>
<Button
type='primary'
disabled={forgotPasswordMutation.isLoading}
loading={forgotPasswordMutation.isLoading}
htmlType='submit'
className='forgot-password-form-button'
>
Reset Password
</Button>
</div>
</Form.Item>
</Form>
</UnauthenticatedPage>
)
}

Reset Password Page

packages/ui/pages/reset-password.tsx
import React, { useEffect } from 'react'
import {
Button,
Typography,
Form,
Input,
} from 'antd'
import { useRouter } from 'next/router'
import Link from 'next/link'
import UnauthenticatedPage from '../components/layouts/UnauthenticatedPage'
import { useResetPasswordMutation } from '../components/Me/meHooks'
const { Title } = Typography
export default function RestPassword() {
const [form] = Form.useForm()
const router = useRouter()
const queryParamEmail = (router.query.email as string) || ''
useEffect(() => {
form.setFieldsValue({
email: queryParamEmail,
})
}, [queryParamEmail])
const resetPasswordMutation = useResetPasswordMutation()
return (
<UnauthenticatedPage pageTitle='Reset Password'>
<Title level={3}>Reset your password</Title>
<Form
layout='vertical'
name='reset_password_form'
form={form}
onFinish={resetPasswordMutation.mutate}
>
<Form.Item
label='Email'
name='email'
required={false}
rules={[
{
required: true,
message: 'Please enter your email.',
type: 'email',
},
]}
>
<Input type='email' />
</Form.Item>
<Form.Item
label='Reset code'
name='code'
required={false}
rules={[
{
required: true,
message: 'Please enter your reset code.',
max: 6,
},
]}
>
<Input />
</Form.Item>
<Form.Item
style={{ marginBottom: '10px' }}
label='New Password'
name='password'
required={false}
rules={[
{
required: true,
message: 'Please enter your password.',
},
]}
>
<Input.Password autoComplete='new-password' />
</Form.Item>
<Form.Item style={{ margin: '0' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button style={{ marginRight: '1rem' }}>
<Link href='/'>Back to login</Link>
</Button>
<Button
type='primary'
disabled={resetPasswordMutation.isLoading}
loading={resetPasswordMutation.isLoading}
htmlType='submit'
className='forgot-password-form-button'
>
Reset Password
</Button>
</div>
</Form.Item>
</Form>
</UnauthenticatedPage>
)
}

Hooks

packages/ui/components/Me/meHooks.ts
'use client'
import axios from 'axios'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
confirmResetPassword,
confirmSignUp,
getCurrentUser,
resetPassword,
signIn,
signInWithRedirect,
signOut,
signUp,
} from 'aws-amplify/auth'
import { useRouter } from 'next/router'
import { notification } from 'antd'
const api = {
getMe: () => axios.get('/me'),
updateMe: ({ data }) => axios.put('/me', { me: data }),
}
export function useCurrentUserQuery({ redirectOnNotAuth = true } = {}) {
const router = useRouter()
const currentUserQuery = useQuery(['currentUser'], async () => {
try {
const currentAuthenticatedUser = await getCurrentUser()
return currentAuthenticatedUser
} catch (error) {
if (redirectOnNotAuth) {
router.push('/')
}
return null
}
}, {
retry: false,
})
return currentUserQuery
}
export function useSignInWithGoogleMutation() {
const signInWithGoogleMutation = useMutation(() => {
signInWithRedirect({ provider: 'Google' })
return new Promise(() => null)
},
{
onError: (err: Error) => {
notification.error({
message: 'Sign in with Google failed',
description: err.message,
placement: 'topRight',
})
},
})
return signInWithGoogleMutation
}
export function useSignInWithSsoMutation() {
const signInWithSsoMutation = useMutation(() => {
signInWithRedirect({
provider: {
custom: process.env.NEXT_PUBLIC_GoogleSamlIdpName!,
},
})
return new Promise(() => null)
},
{
onError: (err: Error) => {
notification.error({
message: 'Sign in with SSO failed',
description: err.message,
placement: 'topRight',
})
},
})
return signInWithSsoMutation
}
export function useSignInMutation() {
const currentUserQuery = useCurrentUserQuery({ redirectOnNotAuth: false })
const signInMutation = useMutation(async ({ email, password }: any) => {
await signIn({ username: email, password })
await currentUserQuery.refetch()
},
{
onError: (err: Error) => {
notification.error({
message: 'Login failed',
description: err.message,
placement: 'topRight',
})
},
})
return signInMutation
}
export function useSignUpMutation() {
const signInMutation = useSignInMutation()
const router = useRouter()
const signUpMutation = useMutation(async ({ name, password, email }: any) => {
await signUp({
username: email,
password,
options: {
userAttributes: { email, name },
},
})
if (process.env.NEXT_PUBLIC_AUTO_VERIFY_USERS) {
await signInMutation.mutateAsync({ email, password })
router.push('/posts')
return
}
router.push(`/verify?email=${encodeURIComponent(email)}`)
},
{
onError: async (err: Error) => notification.error({
message: 'Error',
description: err.message,
placement: 'topRight',
}),
})
return signUpMutation
}
export function useSignOutMutation({ includeEmailQueryStringParam = false } = {}) {
const queryClient = useQueryClient()
const currentUserQuery = useCurrentUserQuery({ redirectOnNotAuth: false })
const signOutMutation = useMutation(async () => {
try {
await signOut({ global: true })
} catch (error: any) {
notification.error({
message: 'Error trying to logout',
description: error.message,
placement: 'topRight',
})
} finally {
queryClient.cancelQueries()
queryClient.clear()
queryClient.invalidateQueries()
queryClient.removeQueries()
window.localStorage.clear()
const signInRoute = includeEmailQueryStringParam ? `/?${currentUserQuery.data?.username}` : '/'
global.window.location.href = signInRoute
}
})
return signOutMutation
}
export function useMeQuery({ isAuthenticated = true } = {}) {
const meQuery = useQuery(['me'], async () => {
const apiResponse = await api.getMe()
return apiResponse.data
}, { retry: false, enabled: isAuthenticated })
return meQuery
}
export function useUpdateMeMutation() {
const queryClient = useQueryClient()
const updateMeMutation = useMutation<any, any, any>(async ({ userId, data }) => {
try {
const response = await api.updateMe({ data })
await Promise.all([
queryClient.invalidateQueries(['me']),
])
return response
} catch (error: any) {
notification.error({
message: 'Update failed',
description: error?.response?.data?.message || error?.message || 'Unknown error',
placement: 'topRight',
})
}
})
return updateMeMutation
}
export function useForgotPasswordMutation() {
const router = useRouter()
const forgotPasswordMutation = useMutation(
async ({ email }: { email: string }) => {
await resetPassword({ username: email })
notification.success({
message: 'Password reset link sent',
description: 'Instructions have been sent to your email.',
placement: 'topRight',
})
await router.push(`/reset-password?email=${email}`)
},
{
onError: async (err: Error) => {
notification.error({
message: 'Forgot password failed',
description: err.message,
placement: 'topRight',
})
},
},
)
return forgotPasswordMutation
}
export function useResetPasswordMutation() {
const signInMutation = useSignInMutation()
const router = useRouter()
const resetPasswordMutation = useMutation(async ({ email, code, password }: { email: string, code: string, password: string }) => {
await confirmResetPassword({
username: email.trim(),
confirmationCode: code.trim(),
newPassword: password.trim(),
})
await signInMutation.mutateAsync({ email, password })
router.push('/')
},
{
onError: async (err: Error) => notification.error({
message: 'Error resetting password',
description: err.message,
placement: 'topRight',
}),
})
return resetPasswordMutation
}
export function useVerifyAccountMutation() {
const router = useRouter()
const verifyAccountMutation = useMutation(async ({ email, code }: { email: string, code: string }) => {
await confirmSignUp({
username: email.trim(),
confirmationCode: code.trim(),
})
notification.success({
message: 'Account confirmed! ๐Ÿ™Œ',
description: 'You may now sign in.',
placement: 'topRight',
}),
router.push(`/?email=${encodeURIComponent(email)}`)
},
{
onError: async (err: Error) => notification.error({
message: 'Error confirming account',
description: err.message,
placement: 'topRight',
}),
})
return verifyAccountMutation
}

Custom Verify User Email

While not strictly part of the React implementation, we need to use a custom Verify User email so that we can link to the correct page and prefill the email and code. Modify the CDK from the first article in this series with this addCustomMessageTrigger method and Lambda Function:

export const handler = async (event, context) => {
if (event.triggerSource === 'CustomMessage_SignUp') {
return handleVerifyUserEmail(event)
}
return event
}
function handleVerifyUserEmail(event) {
const { codeParameter, userAttributes } = event.request
const verifyLink = `https://example.com/verify?email=${encodeURIComponent(userAttributes.email)}&code=${codeParameter}`
const emailMessage = `<div style='text-align: center'>
<p>Please verify your email address to complete account setup.</p>
<p>Your verification code is</p>
<h3>${codeParameter}</h3>
<p>Navigate to <a href="${verifyLink}">${verifyLink}</a>.</p>
</div>`
event.response.emailMessage = emailMessage
event.response.emailSubject ='Verify your account โœ…'
return event
}
packages/cdk/lib/constructs/Auth.ts
addCustomMessageTrigger() {
// Custom message https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-message.html
const { logRetentionInDays } = getEnvironmentConfig(this.node)
const isSourceMapsEnabled = getIsSourceMapsEnabled({ node: this.node })
const cognitoCustomMessageLogGroup = new LogGroup(this, 'CustomMessageLogGroup', {
retention: logRetentionInDays,
})
const environment: StringMap = {}
if (isSourceMapsEnabled) {
environment.NODE_OPTIONS = '--enable-source-maps'
}
const cognitoCustomMessageFunction = new NodejsFunction(this, 'CustomMessageFunction', {
runtime: Runtime.NODEJS_20_X,
handler: 'handler',
entry: path.join(cognitoPackageDir, 'cognito-custom-message.ts'),
timeout: Duration.seconds(10),
memorySize: 1024,
logGroup: cognitoCustomMessageLogGroup,
bundling: {
sourceMap: isSourceMapsEnabled,
},
environment,
})
this.userPool.addTrigger(UserPoolOperation.CUSTOM_MESSAGE, cognitoCustomMessageFunction)
}

End

After reading this series of articles you should understand how a full stack identity solution with AWS Cognito User Pools works. If you havenโ€™t done so already, you can generate a full stack project with this solution and more with a single Code Genie command: