How to create an AWS Cognito custom UI authentication with React using Amplify
Amazon Cognito is an Amazon Web Services product that makes it simple to add user sign-up and authentication to your mobile and web apps. User-end data is saved and stored so that you, the developer, can save time on maintaining the back-end infrastructure. Therefore, Cognito can be considered by most a must-have in terms of speedy devliery.
In this article I will show you how to implement a custom UI for the authentication with Cognito in a React app using the Amplify library. The Amplify framework works best with Cognito and is considered thorough for creating complex, cloud-powered applications. So, if you want trustworthy AWS serverless backend, you have access to a variety of AWS cloud services through Amplify.
However, before we start the guide I must underline that you do requires basic knowledge of react@18 and redux toolkit to move foward.
Curious about the final code? You can find it here: custom-amplify-auth
Important:
Please consider that this approach is NOT fully secured since you store the idToken, accessToken, refreshToken, and other sensitive information about the user in the Local Storage. Therefore, opening the door for XXS and CSRF attacks. See this conversation for more details.
Before configuring Cognito, you need to create an account in Amazon AWS. To make the account, you need to provide credit card information for validation, and they will charge you 1.00 USD. But don't worry. You will get the money back in 3-5 business days and get a free tier for a year.
To keep this article short, I won't go through Cognito configuration step by step, but I will give you an overview of my configuration:
Option | Value |
Cognito user pool sign-in options | |
Password policy | Default |
Multi-factor authentication | No MFA |
User account recovery | Default |
Self-service sign-up | Default(we want users to be able to sign-up by themselves) |
Attribute verification and user account confirmation | Default |
Required attributes | given_name, family_name(keep in mind that these attributes. We need to provide these in our Sign Up component) |
Custom attributes | None(you can add them if you need) |
Configure message delivery | Send an email with Cognito(The preferred option is SES, but see PRICING first to avoid surcharges) |
User Pool name | demo-auth(use your imagination) |
Hosted authentication pages | Unselected(we use our custom design) |
Initial app client | Default |
Advanced app client settings | Authentication flow:ALLOW_REFRESH_TOKEN_AUTH, ALLOW_USER_PASSWORD_AUTH, ALLOW_USER_SRP_AUTHelseDefault |
Before starting the React project, ensure your Node version is the same as mine if you wish to have a similar result. You will need Node version v16.16.
To initialize the app, use the command:
npx create-react-app my-app --template redux-typescript
It will create a boilerplate from where you can start coding and don't have to worry about webpack or babel. All of them are pre-configured and hidden.
Install the Amplify package using the command:
npm install @aws-amplify/auth
In the src/app
directory, create a new file aws-config.ts
where we will configure the Amplify library to use our Cognito user pool for authentication.
import { Auth } from '@aws-amplify/auth' Auth.configure({ region: process.env.REACT_APP_REGION, userPoolId: process.env.REACT_APP_USER_POOL_ID, userPoolWebClientId: process.env.REACT_APP_CLIENT_ID, mandatorySignIn: true, authenticationFlowType: 'USER_PASSWORD_AUTH', })
Here, the Amplify library connects with our Cognito and provides us APIs we will use to build our custom authentication UI.
In order to access those APIs, it needs to be initialized at the start of our application. So, in the index.tsx
at the top, add the next line:
import './app/aws-config' // amplify auth init
Last but not least, create a file in the root directory named .env and add:
REACT_APP_USER_POOL_ID=<ADD_YOUR_USER_POOL_ID> REACT_APP_CLIENT_ID=<ADD_YOUR_CLIENT_ID> REACT_APP_REGION=<ADD_YOUR_REGION>
Now, our application is fully configured and ready to use Amplify for authentication. The only part that our application needs is the authentication UI.
In the src/feature directory, we will add the configuration for our state management.
Create a new directory Auth and inside it, create the following files:
- Auth.service.ts - In here, we'll create our thunk functions
- Auth.slice.ts - In here, we'll add the logic for our reducer
- Auth.types.ts - This is optional. I created it to have a separate file where my custom types will be stored
Now, let's continue with the auth flow. I'll go step by step, starting with:
- First, create our thunk function in the Auth.service.ts
export const signUp = createAsyncThunk('auth/signUp', async ({ email, password, firstName, lastName }: SignUpForm) => { return Auth.signUp({ username: email, password, attributes: { given_name: firstName, family_name: lastName }, }) })
Since we added given_name and family_name as attributes to our Cognito, we need to provide them as part of the user creation. Otherwise, Cognito won't let us create the user.
For more information, see Amplify Sign Up documentation.
- Secondly, create the logic for our Sign Up thunk in our reducer file Auth.slice.ts
Our initial state will have a message prop to provide feedback to the user, a status which will have four states Idle, Loading, Failed, Succeeded, user -for which I had to create a custom type because the library doesn't provide any, along with isAuthenticated.
const initialState: AuthState = { isAuthenticated: false, message: '', status: StoreStatus.Idle, // I created an enum with all 4 states for consistency user: null, } export const AuthSlice = createSlice({ name: 'auth', initialState, reducers: {}, extraReducers: (builder) => {}, })
Now, let's build our use cases as extraReducers.
[...] extraReducers: (builder) => { // Successful response builder.addCase(signUp.fulfilled, (state) => ({ ...state, message: 'Your account is registered. Please check your email for activation instructions.', status: StoreStatus.Succeeded, })) // For pending & rejected requests I added a matcher, because // it will have the same logic for all the requests builder.addMatcher(isAnyOf(signUp.pending), () => ({ ...initialState, status: StoreStatus.Loading, })) builder.addMatcher(isAnyOf(signUp.rejected), (state, { error }) => ({ ...state, status: StoreStatus.Failed, message: error.message || '', })) },
So, when the sign up request succeeds, we set a message and a status. Same when the request fails.
We'll create a specific custom component to show the feedback to the user. But we'll get there later.
When the request is pending, we want to reset the state and set the status to Loading.
- Third, it's time to use our signUp thunk
Create a new directory in the src called pages. In this directory, we'll add all the pages we need.
So, create SignUp.tsx file (you can name it as you wish) and design the form as you like. But remember the first step: we need to have family_name, given_name, email, and password fields to create a user in Cognito.
After the design is done, we need to get the dispatch hook from /src/app/hooks.
const dispatch = useAppDispatch()
And on the submit form, we call the signUp thunk like so:
dispatch(signUp(data))
This way, we fire the Sign Up process. Amplify will send a request to Cognito, and if all the requirements are passed, it will return a data object.
If the request succeeded or failed, we should have a message and a status in our redux state. To display the message to the user, I created a custom component that will be displayed every time a message is in the redux state.
In the /src/components, create a Message.tsx file and import the useAppSelector hook.
import { useAppSelector } from '../app/hooks'
To use it, we do something like:
const { message, status } = useAppSelector((state) => state.auth)
Well, it depends on what you store. In our case, state.auth is an object that has message and status as properties; in this case, we can destroy the auth object and get what we need from it.
I'm using PrimeReact as UI Components. They have a Message component, which has states (severity as they call it): success, info, warn, error.
This custom component will be imported into all our components, where feedback is needed. Otherwise, you can import it once in the App.tsx.
- Create a route for this page
In the App.tsx create a router and add the Sign Up component.
<Routes> <Route path="/sign-up" element={<SignUp />} /> </Routes>
Now that we have Sign Up component ready to go, we also need to confirm the user. So, for that, we need another page.
- First, create the thunk function in the Auth.service.ts
export const confirmSignUp = createAsyncThunk('auth/confirmSignUp', async ({ email, code }: ConfirmSignUpForm) => { return Auth.confirmSignUp(email, code)
As we selected in the AWS Cognito (Cognito user pool sign-in options), the code to confirm the registration is sent to the email.
Here we could choose to send a link instead of code, but then we could not add a custom design for it.
- Secondly, create the logic for Sign Up Confirmation thunk in our reducer file Auth.slice.ts
Since we already have the reducer initialized, the only thing we have to do is to create the logic for Sign Up Confirmation.
[...] extraReducers: (builder) => { // Successful response [...] builder.addCase(confirmSignUp.fulfilled, (state) => ({ ...state, message: 'Account confirmed.', status: StoreStatus.Succeeded, })) // For pending & rejected requests I added a matcher, because // it will have the same logic for all the requests builder.addMatcher(isAnyOf(signUp.pending, confirmSignUp.pending), () => ({ ...initialState, status: StoreStatus.Loading, })) builder.addMatcher(isAnyOf(signUp.rejected, confirmSignUp.rejected), (state, { error }) => ({ ...state, status: StoreStatus.Failed, message: error.message || '', })) },
From now on, this part will be simple. We have to build the case for a successful succeeded request for our thunk function and append the thunk as a parameter in the isAnyOf function followed by .pending or .rejected.
- Third, let's use our confirmSignUp thunk
Create a new page for confirmation with a form where the user can input the code. But, to confirm the user, we also need the email.
So, on the Sign Up page, we have to send the email to the new Sign Up Confirmation page.
useEffect(() => { if (status === StoreStatus.Succeeded) { navigate('/confirm', { state: { email: formData?.email } }) } }, [formData?.email, navigate, status])
My approach here is to navigate (see useNavigate) to the Sign Up Confirm page and send the email field from the form data as state when the status of the request succeeded.
Now on the Sign Up Confirm page, we can get the email using the useNavigate hook from react-router-dom.
;(location.state as Record<string, string>)?.email
I had to enforce the type for location.state as Record, because it has unknown type by default.
When the user submits the confirmation code, we call the confirmSignUp thunk function.
dispatch( confirmSignUp({ email: (location.state as Record<string, string>)?.email, code: data.code, }), ) .then((res) => { if (res.type === 'fulfilled') { navigate('/', { replace: true }) } }) .catch(console.error)
Here, I'm checking if the request succeeded and redirects the user to the Sign In page.
On this page, you can restrict the user from accessing it if there is no email. I recommend you do that.
- Create a route for this page
In the App.tsx add a new route for Sign Up Confirmation page.
[...] <Route path="/confirm" element={<ConfirmSignUp />} />
Another nice thing is being able to resend the confirmation code.
- First, create the thunk function in the Auth.service.ts
export const resendSignUp = createAsyncThunk('auth/resendSignUp', async ({ email }: { email: string }) => { return Auth.resendSignUp(email) })
To resend the confirmation code to the user, we need the email. From there, Cognito will take care of sending the new confirmation code.
- Secondly, create the logic for Resend Sign Up thunk in the reducer file Auth.slice.ts
builder.addCase(resendSignUp.fulfilled, (state) => ({ ...state, message: 'Please check your email for activation instructions.', status: StoreStatus.Succeeded, }))
After the resend request succeeds, we show the user a message that the confirmation instructions are sent to the email. For pending and rejected, there is not much to do. Just append as a parameter in the isAnyOf function (e.g., resendSignUp.pending).
- Third, let's use resendSignUp thunk
In the Sign Up Confirmation page, add a link and an onClick event where we dispatch our thunk function.
dispatch( resendSignUp({ email: String((location.state as Record<string, string>)?.email), }), )
That's all we need to sign up for a new account in our application.
- First, create the thunk function in the Auth.service.ts
export const signIn = createAsyncThunk('auth/signIn', async ({ email, password }: SignInForm) => { const cognitoUser: CognitoUserAmplify = await Auth.signIn(email, password) return cognitoUser.attributes })
I had to add a custom type because the Auth.signIn returns any promise. To see all the attributes, log the cognitoUser variable.
There is also a method you can call to get the attributes getUserAttributes or getUserData, but it takes a callback function and returns void, which, in our case, is not feasible. We need to return the user data from our thunk function to the reducer to be able to store it in the redux for later use.
- Secondly, create the logic for Sign In thunk in the reducer file Auth.slice.ts
builder.addCase(signIn.fulfilled, (state, { payload }) => ({ ...state, status: StoreStatus.Succeeded, isAuthenticated: true, user: payload, }))
Once we get the user attributes from the response, we should set the user data and mark it as authenticated.
- Third, let's use signIn thunk
Create a new page for Sign In with email and password and on form submit dispatch the thunk function.
dispatch(signIn(data))
After sending a request to Cognito we can get a few different responses:
- Status code 400 and a message: User is not confirmed.
- Status code 200 and a challengeName (see DOCS) ~ I won't touch this part since this article is already long enough ~
- Status code 200 and a promise resolve the CognitoUser from where we can extract the user attributes.
When the user is not confirmed, we want to redirect the user to confirm his account and not just show him the message.
useEffect(() => { if (message === 'User is not confirmed.') { navigate('/confirm', { state: { email: formData?.email } }) } }, [formData?.email, message, navigate, status])
So, I added an useEffect and checked the message if the user is not confirmed and redirected to the confirm page along with the email.
For 200 case, I redirect the user to a private page where the user has to be logged in to get access.
The final result will look something like this:
useEffect(() => { if (message === 'User is not confirmed.') { navigate('/confirm', { state: { email: formData?.email } }) } if (isAuthenticated) { navigate('/private') } }, [formData?.email, message, navigate, status, isAuthenticated])
- Create a route for this page
In the App.tsx add a new route for Sign In page.
[...] <Route index element={<SignIn />} />
The next thing to do will be to implement the ability to reset the password if the user forgets it.
- First, create the thunk function in the Auth.service.ts
export const forgotPassword = createAsyncThunk('auth/forgotPassword', async ({ email }: ForgotPasswordForm) => { return await Auth.forgotPassword(email) })
To reset the password for an account, we need the email.
- Secondly, create the logic for Forgot Password thunk in the reducer file Auth.slice.ts
builder.addCase(forgotPassword.fulfilled, () => ({ ...initialState, status: StoreStatus.Succeeded, }))
I added the initialState instead of state because I wanted to ensure that the redux is clean and the message is not carried to the next page.
- Third, let's use forgotPassword thunk
Create a new page for Forgot Password and add a simple input for the email. When the user hits submit, dispatch the thunk function.
dispatch(forgotPassword(data))
Cognito will take care from here and send a unique code to that email. And if the request succeeded, redirect to the Reset Password page. We don't have that page yet but don't worry. We'll get there.
useEffect(() => { if (status === StoreStatus.Succeeded) { navigate('/reset-password', { state: formData }) } }, [dispatch, formData, navigate, status])
- Create a route for this page
In the App.tsx
add a new route for Forgot Password page.
[...] <Route path="/forgot-password" element={<ForgotPassword />} />
Lastly, add a new link on the Sign In page that redirects the user to the Forgot Password page.
- First, create the thunk function in the Auth.service.ts
export const resetPassword = createAsyncThunk('auth/resetPassword', async ({ email, code, password }: ResetPasswordForm) => { return Auth.forgotPasswordSubmit(email, code, password) })
To reset the password, we need the email, the code that Cognito just sent, and a new password that matches the requirements specified in Cognito when we created the User Pool.
- Secondly, create the logic for Reset Password thunk in the reducer file Auth.slice.ts
builder.addCase(resetPassword.fulfilled, (state) => ({ ...state, status: StoreStatus.Succeeded, }))
Here update the status and, as usual, update the isAnyOf function for reject and pending.
- Third, let's use resetPassword thunk
Like on /confirm page, check if the email exists in the state. Otherwise, redirect to Sign In page.
Here we need a code field and a password field, and when the user submits the form, dispatch the thunk function.
dispatch( resetPassword({ email: data.email, code: String(data.code.match(/(\d)+/g)?.join('')), password: data.password, confirmPassword: data.confirmPassword, }), ) .then((res) => { if (res.type === 'fulfilled') { navigate('/', { replace: true }) } }) .catch(console.error)
I'm checking if the request succeeded and redirects the user to the Sign In page.
Here I used the replace true option to restrict the user from returning and adding a different password. Of course, Cognito won't let the user add the password, but why allow them to do that?!
- Create a route for this page
In the App.tsx add a new route for Reset Password page.
[...] <Route path="/reset-password" element={<ResetPassword />} />
- First, create the thunk function in the
Auth.service.ts
export const signOut = createAsyncThunk('auth/signOut', async () => { return Auth.signOut() })
For sign out, I created a thunk to be able to remove all the states from the reducer. Also, if you need to revoke all the sessions, you might want to add { global: true } as a parameter to the Auth.signOut method.
- Secondly, create the logic for Sign Out thunk in the reducer file Auth.slice.ts
builder.addCase(signOut.fulfilled, () => initialState)
- Third, let's use signOut thunk
To use it, just call it anywhere you need. It can be on a button click.
dispatch(signOut()).then(() => navigate('/', { replace: true }))
- First, create the thunk function in the
Auth.service.ts
export const refreshToken = createAsyncThunk('auth/refreshToken', async () => { const cognitoUser: CognitoUserAmplify = await Auth.currentAuthenticatedUser() return cognitoUser?.attributes })
currentAuthenticatedUser
will get the user data from localStorage. If the access token is expired it will make a request to Cognito which will refresh the token for you. So, using Amplify you don't have to put too much effort in it. It work out of the box.
Here you also have the option to instruct Amplify to get a new token directly from Cognito without checking the localStorage first just by adding { bypassCache: true }
as parameter to currentAuthenticatedUser
method.
But we'll keep it as it is to have a faster response.
- Secondly, create the logic for Refresh Token thunk in the reducer file
Auth.slice.ts
builder.addCase(refreshToken.fulfilled, (state, { payload }) => ({ ...state, user: payload, isAuthenticated: true, status: StoreStatus.Succeeded, }))
We will use the refresh token thunk every time our application is rendered. To keep the user authenticated we need to set the user attributes in the reducer, because when the page is refreshed, we lose all the data from redux.
builder.addCase(refreshToken.rejected, (state) => ({ ...state, status: StoreStatus.Failed, }))
I created a separate case for the rejected response because I didn't want to set also the message for it. The reason was that it checks if the user is authenticated, and if not, it will set a message that the user is not authenticated, even though the user just got there.
Make sure that this case is before the builder.addMatcher. Otherwise you'll get an error message like: builder.addCase` should only be called before calling `builder.addMatcher
- Third, let's use refreshToken thunk
In the App.tsx function create a new useEffect and call the refreshToken thunk function.
useEffect(() => { dispatch(refreshToken()) }, [dispatch])
This way, we ensure that when the user refreshes the page, it will refresh token if it is expired and set as authenticated.
The only thing we need to do on the private page is to check if the user is NOT authenticated and redirect to Sign In page.
useEffect(() => { if (!isAuthenticated && [StoreStatus.Succeeded, StoreStatus.Failed].includes(status)) { navigate('/') } }, [isAuthenticated, navigate, status])
I had to check also the status to see if it succeeded or failed. Otherwise, the user will be redirected to the Sign In page every time a refresh is made. The reason for that is the delay in getting the user data and the initial state of the reducer, which is set to be isAuthenticated = false.
And, here you go, you have a functional authentication app using Amplify with custom UI.
This article should cover the basics of creating the authentication flow in ReactJS using Amplify. The approach presented should be fine for a start-up or a personal project. Still, if your application grows, you might need to implement a different flow, including a back-end service with better security. Favor a way where the front-end has no rights to access or modify the access and refresh tokens.
If you wish to continue reading about AWS products (such as AWS Lambda) and our tips and tricks serverless computing we have you covered. We have articles about serverless computing and tutorials on how to avoid serverless resource limiting when using GraphQL.
Thanks for reading, and happy coding!