Introduction
Do you need authentication in your React app? Only if your website has content that should be protected. Sometimes you may have content that should not be visited by all users or visitors of your site. You may also have pages that can only be visited if a user is authenticated. For example, a profile page should only be visited if a user is logged in. If they are not logged in, the profile page should not be accessible. However, pages are not the only content you may need to protect. You may want to protect data that you’re storing in a database, API endpoints (like a change password request) that you only want accessible for authenticated users, and more. Authentication is handy if you want pages inaccessible when a user is not authenticated, and also if you want to keep endpoints that can, for example, change a user’s password accessible only for authenticated users. There are many more examples, of course, but let’s dive in further.
How It Works
Authentication is a two-step process. Think of it like a lock and key. If you have a key, you can gain access anywhere the lock matches the key. Similarly, in authentication, you log in with credentials (the key), which gets sent to a server and verifies your credentials in a database (the lock). If valid, you have permission to enter, and that permission persists as you navigate the site. In our example earlier, you need permission to log in, but you also need permission to access the profile page. Using API endpoints, we use the permission granted at log in to help us access other protected parts of the application. However, the permission granted can come in different ways, either server-side or with authentication tokens.
Server-side authentication is a very traditional method where the server stores a unique identifier for your client after the access has been granted, and this identifier is also sent back to the client. That way, as you navigate the site, this identifier is sent back to the server from the client, and because it is unique, the server can verify the request. However, this method comes with a significant disadvantage, particularly for single page applications (SPAs). If your SPA is hosted on one server, but your REST API is served by another server, your front end is not tightly coupled. Consider an API that is used widely, like the Twitter API. Obviously this API can’t be tightly coupled to your server and your one specific front end. It needs to be flexible, and therefore you can’t store an identifier on the server as it needs to remain stateless. It should not be storing this kind if data about its connected clients.
With SPAs, you will often work with decoupled back end and front end setups, and here you use authentication tokens. While you are still sending unique identifiers between the server and client, the identifier itself is created, stored, and utilized in a different way. First, when the client asks for permission, it sends the credentials to the server, where the credentials are still verified and stored. The server then creates a unique token based on a key that it uses to encrypt some data based on the user and sends this token back to the client. Generally, these are created in the “JSON Web Token” format, and are essentially long strings of encoded data created using the server’s private key. Instead of storing a unique identifier on the server as we saw in server-side authentication, this token gets stored on the client. Therefore, whenever the client needs further access (when navigating to a protected page, for example), the client sends this token back to the server, and the server checks it by determining if the token could have been created by the server with the server’s private key. Below, I will outline my process in implementing this approach.
Implementation
For my setup, I used Firebase as my back-end with the Firebase Authentication REST API. The documentation is very clear with API endpoints for most authentication needs, like creating a user with email and password, logging in a user, or getting access to protected resources like changing a password. Its of course possible to write your own back-end code, but that would have been overkill for my project’s needs.
To get started, you will need to create a Firebase project and unlock authentication. You have options when choosing your sign in method. For my example, I used email and password.
Sign Up
When creating your sign up form, you will need to send the email and password credentials to the dedicated email/password sign up API endpoints. Create a submit handler function that is triggered when the user submits the form. Prevent the browser from sending a request automatically by calling event.preventDefault()
, because we will be sending our own request. To extract the entered data, you can log every keystroke using state, but I used refs with connected refs to the input elements. Create your ref hooks with useRef()
and then set the ref attribute for the input elements. In the submit handler, you can extract the values of those form elements by using the ref’s value, for example emailInputRef.current.value
. Here is where you would also add validation to ensure you have a real email address and a secure password.
You currently don’t have sign up or log in setup. Eventually both of these will use the same logic with different API endpoints, but for now, start by setting up the sign up functionality. Find the sign up with email and password API route in the Firebase Auth REST API documentation. You will see that it is a POST request to the URL and what data is attached to create a new user. Take this URL and send that HTTP request using the fetch function. You may want to use custom hooks or Redux here, but in this example we will call fetch directly in the authentication form component. When adding the API endpoint, you will need to edit it with your project’s specific API key. This points Firebase to your project, where you want to create a new user. Create the second argument of the fetch function by passing in an object that describes your request. Again, the method will be a POST request. The body will be in JSON format and you will need to set an email
, password
and returnSecureToken
which should always be set to true. Add a content type header to the outgoing request that is set to JSON. So now we have a request that is ready to send, but what will we do with the response? We will need to handle errors and do something with the data that is sent back. To that end, add a catch
to the fetch function in order to handle errors, and a then
to handle the response. You can also handle errors in the response data, because this data will hold additional information if the response is not successful. You can use async await to handle this, too, but I have used a nested promise chain that checks if the response and returns the JSON data if it is ok and throws an error if it is not. Connect the submit handler to your form and you should now be able to create a new user. You will not be able to test this on your app yet, because you have not added log in functionality, but you can check your Firebase database and see the user you have just created through your form.
You may choose to use the error messages that come through the response data (data.error.message
) or setup a general “Authentication failed” message for your error handling, but in either case it is a good idea to setup an error handling message as part of your form. To further a good user experience, you may also want to show a loading state while the request is sending. Do so by creating a piece of state isLoading
with setIsLoading
as its state updating function, originally setting it to false. Set it to true just before you begin the fetch method. Once you have received a response – so within the then
block, you can set it back to false. Now you can show the submit button and a loading spinner conditionally based on whether isLoading
is true or false.
Log In
Checking the Firebase Auth REST API documentation for logging in with email and password, you will see that you are sending almost the same request as signing up. The method, body, and headers are completely the same, and the only difference is the API endpoint. For this reason, DRY (Don’t Repeat Yourself) and share the same code where possible. First, create a isLogin
state that toggles true/false depending on if you are on the Sign Up or Log In form. I just created the same form and showed the title, button, and link to the other form with their text values dependent on whether isLogin was true or false (for example, the title reads “Sign Up” when isLogin is false and “Log In” when isLogin is true). Create a helper variable for the API endpoint URL and assign it within an if/else with the conditional being isLogin
. Place the fetch block immediately after this, and replace the hard-coded API endpoint with the URL variable. Now your request should succeed for both Log In and Sign Up. However, let’s handle the response, because even though the response data will be different if you login or sign up, you will receive an authentication token in both responses. So in your nested promise, you first check if the response is ok, and if it is, then return the response JSON data. If it is not ok, then throw a new error. Now you want to add another then
block to handle the response data and a catch
block to handle the thrown error. You can test this by doing a logging the response data in the console, or displaying an error in an alert box (try signing in with incorrect credentials, or, if you haven’t setup validation on the password, Firebase will send an error if you trying signing up with a password that is less than 7 characters). Your response payload will include the ID token, which is the authentication token, and now we will be able to use that in future requests.
Storing the Authentication Token
Once you’re signed up or logged in, your front-end interface and user experience will probably change significantly across the site. A lot of components, like your navigation, and access to protected resources, like the profile page, will need to know whether the user is logged in or not. To manage this app-wide state, you have a couple of options, including Redux or the context API. For this example, I am using the context API. This way, I do not need to download another third-party package, I know that my authentication state will not change very much, and therefore I don’t expect performance issues.
To add the context API, add a JavaScript file where you will set up and manage the context for the authentication data. You will create your authentication context with React.createContext
, initializing it with data that will define the context and allow for better auto-completion. In your initial data you will have the token set as an empty string, an isLoggedIn
boolean set to false, a login function that passes in your token to an empty function, and a logout function that passes in and does nothing at the moment.
Now create the provider component for the authentication context you have just setup. This will serve as a wrapper around your entire app and will manage its authentication state. Therefore, it will receive the children as props. As this component is managing state, use useState
and for creating and setting the token. With this token state, we will be able to determine whether the user is logged in or not. To set a boolean showing if the user is logged in, use !!
in front of the token you just set in useState
. This checks if the token exists and converts it to a truthy or falsy value. Finally, you will need handlers to change the token state, so create login and logout handlers. In the logout handler, set the token to null again. The login handler will need to accept a token as an argument and then be set to that incoming value. Now export the context as default and export the provider as a named export, go to your app’s file, and wrap the provider component around your entire app.
Now you can tap into this context component from the entire app using useContext
. Go back to your authentication form and create the authentication context (something like const authenticationContext = useContext(AuthenticationContext)
). Now you are able to pass the token received in your response data on the login of this context. Now that you have set the token, the sky is the limit for your UI. You can conditionally render content based on whether your isLoggedIn
in your context is set to true or not. You can also tap into the context if you need the ID token, which would be a necessary part of your request body when, for example, sending a request to the change password. If you would like to redirect the user when logging in, signing up, etc., you can use the Redirect component, which is part of React Router.
Log Out
You may be able to determine what logic is needed at this point in order to log out. What is the app using to drive the user’s logged in experience? That’s right: the authentication token. So we don’t actually need to do anything with API endpoints and any conversation between the client and the server. All we need to do is clear the state that holds the token, which of course we have stored on the client. Therefore, wherever logout functionality is needed, you need only access the authentication context provider and bind the logout handler function that we setup before. Once the user is logged out, you may want to redirect them away from the page they were on, especially if it was a protected resource.
Access Protected Resources When the User Is Logged In
When logged out, there are a number of pages we probably don’t want a user to access. Let’s go back to the profile page example. If I know the URL to a profile page, I certainly shouldn’t be able to just type it in and access it if I am logged out. So how do I protect that page, that resource? We use navigation guards.
Navigation guards are rather simple, actually. We simply change our route configuration whether the user is logged in or out. Most likely your routes are determined in your main component, and this is where you setup the navigation guards. Return to the authentication status you used to conditionally render the UI, the isLoggedIn
boolean you setup in the authentication context. Just as you conditionally rendered parts of your UI, you will do the same for your routes. You can also redirect the user if they try to access an unauthorized page, perhaps to a 404 page. In this case, I will redirect them back to the home page with the help of the react-router-dom redirect component.
<Route path="/profile">
{authorizationContext.isLoggedIn && <UserProfile />}
{!authorizationContext.isLoggedIn && <Redirect to="/" />}
</Route>
Authentication Persistence
A major consequence of storing your authentication token in the client is that its state will reload every time you reload the browser or manually enter a URL to your app because you are restarting your app, losing the context, and reverting back to its initial state. Of course, that’s not ideal and your user would most likely expect to still be logged in. You are probably used to remaining logged in after signing in, but after some time, you are logged out. That is because tokens expire after a set duration, often defaulting to one hour. This is a security mechanism. Fortunately, we not only receive the authentication token as part of the response when signing up or logging in. We also receive “debt duration”, the time left where the token will be valid. While it is possible to refresh the token, here we will work with the debt duration data and log the user out once it has expired.
There is still one more problem: the token is being stored in state, and that will be cleared every time the page reloads. Fortunately, browsers have other means of storing information. For one, cookies, but we will use an even easier mechanism which is local storage. It’s worth understanding both options. Neither are fool-proof solutions and for posterity’s sake, it’s worth noting that local storage is vulnerable to cross-site scripting attacks. Nevertheless, we soldier on. So let’s store in local storage, which stores simple information that survives page reloads. Then when we load the app, we can check to see if that information exists so we don’t have to force a new request to the server.
You will use localStorage.setItem()
to set the name and value, or a key/value pair, of the the token. This uses an API that’s built into the browser, which you have also used with the fetch function. Use localStorage.removeItem()
in your logout handler to clear the token.
Now when you start the app, you will want to see if there’s a token in your local storage. If it exists, you will initialize your state with that token, which will then continue on with the logic you setup for authenticating the user. Previously, we were initializing the token state as null, but now we want to check if the token exists using localStorage.getItem()
and then setting that value in our useState()
hook. The token may not exist in our local storage, but in that case it will appear as undefined and still work here, because local storage is a synchronous API.
Logging Users Out Automatically After Some Time
There’s one issue remaining that needs to be solved. As mentioned earlier, the issued token comes with an expiration date. In our logic, we have set a token in our user storage, but what happens when its expiration date has passed? No logic has cleared it from storage at that time interval, so the app still believes it has a valid token. However, if you were to make any calls back to the Firebase Auth API, and that call needed a token (for example, if you were to change your password), you would no longer have a valid one and the call would return an error. We need to clear the token once the duration date has passed so that our app remains in sync with the tokens issued by the server.
To do this, we need to store the duration date alongside the token in local storage, and then calculate the remaining time by subtracting the current time from the duration date. First, calculate the expiration time when you are passing in data from the authentication API by adding the expiresIn
time to the current time. Pass this to your login handler function in the authentication context file. Now in your authentication context file, add a new helper function which takes this expiration time as an argument and calculates the remaining time by subtracting the expiration time from the current time and returns the remaining duration. You will be calling the logout handler when the token has expired using setTimeout()
, so be sure to calculate in milliseconds.
Now we need to setup the timer that will logout when the remaining duration is zero. In the login handler, create a variable that is equal to the returned value of the helper function that returns the remaining duration value when you pass in the expiration time, which you have here because you are now passing it into the login handler function when receiving data from the API. Use setTimeout()
to call the logout handler when that time has expired.
We must also clear it if the user logs out manually. Create a global variable for the timer and set it to the timer you have just created. Now in your logout handler, check if the timer variable has been set, and if it has been, clear the timer using clearTimeout()
.
You must also clear the token you have stored in local storage if the timer has expired, of course. Create another helper file in the authentication context and get the token and expiration time from local storage (you can set the expiration time in local storage in your login handler). As you did before, calculate the remaining time using the helper function you used and passing in the value stored in local storage. If the remaining time is less than or equal to zero, then remove the token and expiration time from local storage and return null. If there is still time, then return the stored token and stored remaining time. Initialize the token with the stored token value (under the condition that it exists). Use useEffect()
with this data as a dependency and then within the function, check if the data exists, and if it does, set the timer again with the data’s remaining time. Be sure to also remove the remaining time from local storage in the logout handler. Now we have set the user’s remaining time if we have automatically logged in the user.
Conclusion
Unlike many tutorials, I have not supplied most of the code here. This walkthrough is mostly a way to read through a potential solution for adding authentication to your project using tools available in your client and React. There are almost always different ways to go about finding solutions and so I wish you happy coding!