AWS Cognito User Pools: Add Auth to a React App

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:
- Create Resources with CDK
- Sign in with Email, Google, or SAML and link to a single user
- 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:
- Configuring the Amplify library with details about our Cognito User Pool
- Configuring Axios with the base URL of our API endpoint, and attaching the Authorizers header on each API request.
- Adding pages for Login, Register, Verify, Forgot Password, Reset Password. See Code Genie pages docs for more details.
- 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
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 pageaxios.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
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
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
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
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
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
'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}
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: