Apr 13, 2023
Gideon Idoko
Learn how to implement OAuth 2.0 in a React app for user authorization. OAuth 2.0 lets users share information securely without passwords.
In today's digital age, applications are used for everything. From social media to online banking, so much can be done online. However, as society's reliance on online services grows, so does the need to keep personal information secure. This is where OAuth 2.0 comes in.
OAuth 2.0 is a popular protocol for both developers and users that lets you share certain information with a third-party app without providing a password. It's become the standard for many online businesses because it's secure and easy to use.
In this article, you'll learn more about OAuth 2.0 and the different ways it can be used. You'll also learn how to implement OAuth 2.0 in a React application for user authorization.
The first step in understanding OAuth 2.0 is understanding that while there are similarities between authentication and authorization, the two concepts are different.
Authentication is the process of verifying the identity of a user or system. Using a set of credentials, such as a username or password, authentication makes sure that users are who they say they are.
Meanwhile, authorization is the process of figuring out if an authenticated user has the right privileges to access certain resources or do certain things. It's mainly focused on figuring out what a user can do and what resources they can use based on their role or other characteristics.
Authentication and authorization are both important parts of web security. They work together to make sure that only people who are allowed to can use a system or its resources.
Now that you know how authentication and authorization work, let's dig into what OAuth 2.0 is.
OAuth 2.0, also referred to as the OAuth 2.0 Authorization Framework, is an open standard for authorization that lets users give third-party applications limited access to their web server resources without giving the applications their private credentials. OAuth 2.0 gives users more control over their data; they can selectively grant access to the applications they want to use.
For instance, OAuth 2.0 can be utilized to help users sign in to third-party applications with their Google account. This is often referred to as social login and is so common you've probably used it.
Here is a brief explanation of what occurs when you use a social login:
This five-step process defines the OAuth 2.0 flow or grant type. An OAuth 2.0 flow (ie an OAuth 2.0 grant type) typically defines the steps involved in getting an access token, as seen here:
By following this implicit OAuth 2.0 flow (more on this later), you can use a third-party application without giving it access to your Google credentials. You can also stop the application from using your account at any time.
OAuth 2.0 identifies a number of different grant types, or OAuth 2.0 flows, that can be used to get an access token (which is used to access a protected resource). The OAuth 2.0 flow chosen usually depends on the client application, the protected resource, and the trust between the client and the server that does the authorization.
The following are some of the most popular types of OAuth 2.0 flows:
In an authorization code flow, the resource owner is sent from the client application to the authorization server's authorization endpoint. Then the resource owner logs in and grants consent to the client, and is sent back to the client with an authorization code. Then the client exchanges this code for an access token.
Web applications commonly use this flow; however, it can also be used by mobile apps using the Proof Key for Code Exchange (PKCE) technique.
The implicit flow with form post is similar to the authorization code flow but is used for browser-based client applications, especially single-page apps. In this case, instead of trading an authorization code for an access token, the authorization server gives the access token directly to the client application, as in the example flow discussed previously.
In a resource owner password flow, the resource owner already knows the client and feels comfortable giving them access to their credentials. The owner of the resource gives the client their private credentials directly, which the client then trades for an access token.
In the case of a client credentials flow, the client uses their own credentials to access protected resources instead of using the credentials of the resource owner. Afterward, the authorization server gives the client an access token.
OAuth 2.0 roles define the entities in an OAuth 2.0 flow, and there are four major roles:
Now that you have a better understanding of OAuth 2.0 and the different roles and flows, let's implement OAuth 2.0 in a React application.
In this section, you'll learn how to implement an OAuth 2.0 authorization code flow in your React application. Additionally, you'll learn a less complicated approach to implementing user authorization with Clerk.
All the code for this tutorial can be found in this GitHub repo.
Before you begin, you'll need Node.js version 16 or later installed on your machine. Node.js ships with npm, which you'll use to install the necessary packages.
In addition, it's recommended that you have a basic knowledge of React and JavaScript to help you complete this tutorial with ease.
So far, you've only learned about the various OAuth 2.0 flow types. Here, you'll implement one.
The authorization code flow is the most secure flow for web server applications, and this is how it works:
There are three main components: the authorization server, the frontend, and the backend. The final component is the resource (which is part of the backend) that only authorized users can access. In order for a user to be authorized, they must complete the authorization code flow:
In this scenario, the frontend is a React application, and the backend/resource is a Node.js application. Building the authorization server is beyond the scope of this article, so instead, you'll leverage Google OAuth 2.0.
Alright, let's get started!
Interacting with the authorization server to get the authorization code is the first step in the OAuth 2.0 flow. You'll need a Google client ID and secret to be able to achieve that.
Follow these steps to obtain the client ID and secret:
Name it. In this example, it's "standard-auth".
Locate the More Products section on the sidebar, and click on APIs & Services and then the OAuth consent screen. Select External user type and click on CREATE. This will create a new OAuth consent screen. The OAuth consent screen tells users what app is asking for access to their information and what information the app can access:
The redirect URI specifies where Google should navigate after the consent screen. This means that the redirect or callback has to be created on the React frontend.
Now that you have your client ID and secret, it's time to implement the backend with Node.js and the Express framework.
To do so, create a new directory (ie standard-auth
) to house the backend project:
1mkdir standard-auth
Then create another directory inside it called server
:
1cd standard-auth && mkdir server
Initialize a new project within the server
directory:
1npm init esnext -y
And install the necessary packages by running this command:
1npm install axios@1.3.4 cookie-parser@1.4.6 cors@2.8.5 dotenv@16.0.3 express@4.18.2 jsonwebtoken@9.0.0 query-string@8.1.0
Create a new .env
file and add the following to it:
1GOOGLE_CLIENT_ID=<the client ID you created earlier>2GOOGLE_CLIENT_SECRET=<the client secret you created earlier>3REDIRECT_URL=http://localhost:3000/auth/callback4CLIENT_URL=http://localhost:30005TOKEN_SECRET=<any random string>
Update the environment variables accordingly.
Create a new server.js
file, which is where the server-side code will be stored:
1touch server.js
Add the following code to server.js
to import all the necessary dependencies and create a config object:
1import 'dotenv/config';2import express from 'express';3import cors from 'cors';4import axios from 'axios';5import queryString from 'query-string';6import jwt from 'jsonwebtoken';7import cookieParser from 'cookie-parser';89const config = {10clientId: process.env.GOOGLE_CLIENT_ID,11clientSecret: process.env.GOOGLE_CLIENT_SECRET,12authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',13tokenUrl: 'https://oauth2.googleapis.com/token',14redirectUrl: process.env.REDIRECT_URL,15clientUrl: process.env.CLIENT_URL,16tokenSecret: process.env.TOKEN_SECRET,17tokenExpiration: 36000,18postUrl: 'https://jsonplaceholder.typicode.com/posts'19};
Here, config.authUrl
is a link to the OAuth consent screen. It is sent along with some query parameters to the frontend. Then a request is made to config.tokenUrl
, along with some query parameters to verify an authorization code.
You need to define the parameters for both URLs by adding the following code to server.js
:
1const authParams = queryString.stringify({2client_id: config.clientId,3redirect_uri: config.redirectUrl,4response_type: 'code',5scope: 'openid profile email',6access_type: 'offline',7state: 'standard_oauth',8prompt: 'consent',9});10const getTokenParams = (code) => queryString.stringify({11client_id: config.clientId,12client_secret: config.clientSecret,13code,14grant_type: 'authorization_code',15redirect_uri: config.redirectUrl,16});
Initialize a new Express app:
1const app = express();
And add the following middlewares to resolve CORS, parse cookies, and verify authorization, respectively:
1// Resolve CORS2app.use(cors({3origin: [4config.clientUrl,5],6credentials: true,7}));89// Parse Cookie10app.use(cookieParser());1112// Verify auth13const auth = (req, res, next) => {14try {15const token = req.cookies.token;16if (!token) return res.status(401).json({ message: "Unauthorized" });17jwt.verify(token, config.tokenSecret);18return next();19} catch (err) {20console.error('Error: ', err);21res.status(401).json({ message: "Unauthorized" });22}23};
At this point, you can start adding endpoints for different tasks.
Add the following endpoint (/auth/url
) to return the authorization URL to the frontend:
1app.get('/auth/url', (_, res) => {2res.json({3url: `${config.authUrl}?${authParams}`,4});5});
Then add another endpoint (/auth/token
) that will get an authorization code from the frontend and verify it:
1app.get('/auth/token', async (req, res) => {2const { code } = req.query;3if (!code) return res. status(400).json({ message: 'Authorization code must be provided' });4try {5// Get all parameters needed to hit authorization server6const tokenParam = getTokenParams(code);7// Exchange authorization code for access token (id token is returned here too)8const { data: { id_token} } = await axios.post(`${config.tokenUrl}?${tokenParam}`);9if (!id_token) return res.status(400).json({ message: 'Auth error' });10// Get user info from id token11const { email, name, picture } = jwt.decode(id_token);12const user = { name, email, picture };13// Sign a new token14const token = jwt.sign({ user }, config.tokenSecret, { expiresIn: config.tokenExpiration });15// Set cookies for user16res.cookie('token', token, { maxAge: config.tokenExpiration, httpOnly: true, })17// You can choose to store user in a DB instead18res.json({19user,20})21} catch (err) {22console.error('Error: ', err);23res.status(500).json({ message: err.message || 'Server error' });24}25});
If the authorization code is verified (ie exchanged for an access token successfully), a new token is signed for the current user. The signed token will then be set as a cookie that will expire based on the tokenExpiration
key in the config
object.
Add the /auth/logged_in
endpoint to check the logged-in state of a user:
1app.get('/auth/logged_in', (req, res) => {2try {3// Get token from cookie4const token = req.cookies.token;5if (!token) return res.json({ loggedIn: false });6const { user } = jwt.verify(token, config.tokenSecret);7const newToken = jwt.sign({ user }, config.tokenSecret, { expiresIn: config.tokenExpiration });8// Reset token in cookie9res.cookie('token', newToken, { maxAge: config.tokenExpiration, httpOnly: true, })10res.json({ loggedIn: true, user });11} catch (err) {12res.json({ loggedIn: false });13}14});
Then add the /auth/logout
endpoint to log out a user in session:
1app.post("/auth/logout", (_, res) => {2// clear cookie3res.clearCookie('token').json({ message: 'Logged out' });4});
This will clear the token
cookie, which invalidates a logged-in user's session.
Finally, add the resource endpoint:
1app.get('/user/posts', auth, async (_, res) => {2try {3const { data } = await axios.get(config.postUrl);4res.json({ posts: data?.slice(0, 5) });5} catch (err) {6console.error('Error: ', err);7}8});
This basically fetches posts (resources) and returns them as a response. The middleware for validating user authorization (auth
) is used here because only authorized users are allowed to access this endpoint.
Now you need to make the Express app listen on port 5000
:
1const PORT = process.env.PORT || 5000;23app.listen(PORT, () => console.log(`🚀 Server listening on port ${PORT}`));
The easiest way to start a React project is to use boilerplate code. Go to the standard-auth
directory and bootstrap a new React project using Create React App:
1npx create-react-app client
This will create a client
directory with your React project inside it.
Change to the client
directory using cd client
and install the following packages:
1npm install axios@1.3.4 react-router-dom@6.9.0
Next, create a .env
file in the root directory of your React project:
1echo REACT_APP_SERVER_URL = http://localhost:5000 > .env
Open the App.css
file in the src
directory of your React project and add the following CSS code:
1.btn {2border-radius: 100rem;3padding: 12px 16px;4background: linear-gradient(170deg, #61DBFB, #2e849b) !important;5width: 10rem;6border: none;7color: white;8font-weight: 600;9margin: 1rem 0;10cursor: pointer;11}
This will help style a button using the btn
class.
Update your App.js
file to import the necessary method and hooks by adding the following code just above the App
component:
1import { RouterProvider, createBrowserRouter, useNavigate } from 'react-router-dom';2import axios from 'axios';3import { useEffect, useRef, useState, createContext, useContext, useCallback } from 'react';45// Ensures cookie is sent6axios.defaults.withCredentials = true;78const serverUrl = process.env.REACT_APP_SERVER_URL;
After updating the App.js
file, you need to create a new React context to hold the logged-in and user states so they can be shared globally. To do so, add the following code just above the App
component:
1const AuthContext = createContext();23const AuthContextProvider = ({ children }) => {4const [loggedIn, setLoggedIn] = useState(null);5const [user, setUser] = useState(null);67const checkLoginState = useCallback(async () => {8try {9const { data: { loggedIn: logged_in, user }} = await axios.get(`${serverUrl}/auth/logged_in`);10setLoggedIn(logged_in);11user && setUser(user);12} catch (err) {13console.error(err);14}15}, []);1617useEffect(() => {18checkLoginState();19}, [checkLoginState]);2021return (22<AuthContext.Provider value={{ loggedIn, checkLoginState, user }}>23{children}24</AuthContext.Provider>25);26}
The checkLoginState
function in this React context makes a call to your backend's /auth/logged_in
endpoint. Here, it's called in a useEffect
block to run every time the app initially renders.
Next, add the following Dashboard
component just before the App
component:
1const Dashboard = () => {2const { user, loggedIn, checkLoginState } = useContext(AuthContext);3const [posts, setPosts] = useState([]);4useEffect(() => {5(async () => {6if (loggedIn === true) {7try {8// Get posts from server9const { data: { posts } } = await axios.get(`${serverUrl}/user/posts`)10setPosts(posts);11} catch (err) {12console.error(err);13}14}15})();16}, [loggedIn])1718const handleLogout = async () => {19try {20await axios.post(`${serverUrl}/auth/logout`);21// Check login state again22checkLoginState();23} catch (err) {24console.error(err);25}26}2728return (29<>30<h3>Dashboard</h3>31<button className="btn" onClick={handleLogout} >Logout</button>32<h4>{user?.name}</h4>33<br />34<p>{user?.email}</p>35<br />36<img src={user?.picture} alt={user?.name} />37<br />38<div>39{posts.map((post, idx) => <div>40<h5>{post?.title}</h5>41<p>{post?.body}</p>42</div>)}43</div>44</>45)46}
Later, you'll render this component conditional based on the logged-in state of a user.
Add the Login
component:
1const Login = () => {2const handleLogin = async () => {3try {4// Gets authentication url from backend server5const { data: { url } } = await axios.get(`${serverUrl}/auth/url`);6// Navigate to consent screen7window.location.assign(url);8} catch (err) {9console.error(err);10}11}12return <>13<h3>Login to Dashboard</h3>14<button className="btn" onClick={handleLogin} >Login</button>15</>16}
This component renders a button that takes the user to the OAuth consent screen you made earlier.
Do you remember when you provided a callback URL when you created your client ID and secret? Because of this, you need to add a component to handle the callback:
1const Callback = () => {2const called = useRef(false);3const { checkLoginState, loggedIn } = useContext(AuthContext);4const navigate = useNavigate();5useEffect(() => {6(async () => {7if (loggedIn === false) {8try {9if (called.current) return; // prevent rerender caused by StrictMode10called.current = true;11const res = await axios.get(`${serverUrl}/auth/token${window.location.search}`);12console.log('response: ', res);13checkLoginState();14navigate('/');15} catch (err) {16console.error(err);17navigate('/');18}19} else if (loggedIn === true) {20navigate('/');21}22})();23}, [checkLoginState, loggedIn, navigate])24return <></>25};
Create a router to define routes that render the components you created earlier:
1const Home = () => {2const { loggedIn } = useContext(AuthContext);3if (loggedIn === true) return <Dashboard />;4if (loggedIn === false) return <Login />5return <></>;6}78const router = createBrowserRouter([9{10path: '/',11element: <Home />,12},13{14path: '/auth/callback', // google will redirect here15element: <Callback />,16}17]);
Finally, update the App
component with the following code:
1function App() {2return (3<div className="App">4<header className="App-header">5<img src={logo} className="App-logo" alt="logo" />6<AuthContextProvider>7<RouterProvider router={router} />8</AuthContextProvider>9</header>10</div>11);12}
Now, it's time to test out the implementation. Go to your server
directory and run the following command to spin up the server:
1node server.js
Open another terminal in your client
directory and run this command to spin up your React server:
1npm start
This command will open up http://localhost:3000 in your default browser. You should see something like this when the server is fully running:
If you click on the login button, you'll be redirected to the OAuth consent screen:
After you're redirected, you'll be logged in and authorized to access the post resources:
If you've followed along, you've officially implemented the OAuth 2.0 authorization code flow in React. Great work!
As you can see, implementing OAuth 2.0 is complicated and time-consuming. This is where Clerk can help.
Clerk is a service that takes care of user management. This means developers don't have to keep reinventing the wheel and can focus on what they do best. Moreover, Clerk can also take care of authorization.
In this section, you'll see how Clerk can easily be used to replicate the implementation in the previous section.
Before you proceed, you need to have a Clerk account. An account will give you access to both a publishable key and a secret key, as well as other customizations.
If you don't already have an account, go to Clerk's sign-up page and create one:
After you've signed up, click on Add application to create a new project:
Update the settings for your new application to match the settings shown here:
When you're done, click on FINISH, and a new application will be created.
Navigate to the API Keys section. Copy your publishable and secret keys, and save them somewhere safe:
For this Clerk example, you need to rewrite the Node.js implementation you did previously. Update your .env
file in the server
directory with the following:
1CLERK_SECRET_KEY=<the secret key you copied from your Clerk account>
Update the environment variable accordingly.
Then run the following command in your server
directory to install Clerk's Node.js SDK:
1npm install @clerk/clerk-sdk-node@4.7.11
Update your server.js
file with the following code:
1import 'dotenv/config';2import express from 'express';3import cors from 'cors';4import axios from 'axios';5import { ClerkExpressRequireAuth } from '@clerk/clerk-sdk-node';67const config = {8postUrl: 'https://jsonplaceholder.typicode.com/posts',9clerkSecretKey: process.env.CLERK_SECRET_KEY, // Clerk automatically picks this from the env10};1112const app = express();1314app.use(cors());1516app.get('/user/posts', ClerkExpressRequireAuth(), async (req, res) => {17console.log('REQUEST AUTH: ', req.auth);18try {19const { data } = await axios.get(config.postUrl);20res.json({ posts: data?.slice(0, 5) });21} catch (err) {22console.error('Error: ', err);23}24});2526const PORT = process.env.PORT || 5000;2728app.listen(PORT, () => console.log(`🚀 Server listening on port ${PORT}`));
Here, you're using the ClerkExpressRequireAuth()
middleware to allow only authorized access to the /user/posts/
route. It's that easy!
To implement the React frontend, update the .env
file in your client
directory with the following:
1REACT_APP_CLERK_PUBLISHABLE_KEY=<the publishable key you copied from your Clerk account>2REACT_APP_SERVER_URL = http://localhost:5000
Remember to update the environment variable accordingly.
Then run the following command in the client
directory to install the Clerk React SDK:
1npm install @clerk/clerk-react@4.12.4
Update the App.js
file inside the src
directory in your client
directory with the following code:
1import logo from './logo.svg';2import './App.css';3import { ClerkProvider, SignedIn, SignedOut, UserButton, RedirectToSignIn, useAuth } from '@clerk/clerk-react';4import { useState, useEffect } from 'react';5import axios from 'axios';67const clerkPubKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY;8const serverUrl = process.env.REACT_APP_SERVER_URL;910const Dashboard = () => {11const [posts, setPosts] = useState([]);12const { getToken } = useAuth();131415useEffect(() => {16(async () => {17try {18const token = await getToken();19console.log('token: ', token)20// Get posts from server21const { data: { posts } } = await axios.get(`${serverUrl}/user/posts`, {22headers: { Authorization: `Bearer ${token}` }23})24setPosts(posts);25} catch (err) {26console.error(err);27}28})();29// eslint-disable-next-line react-hooks/exhaustive-deps30}, [])3132return (33<>34<h3>Dashboard</h3>35<UserButton />36<div>37{posts.map((post, idx) => <div>38<h5>{post?.title}</h5>39<p>{post?.body}</p>40</div>)}41</div>42</>43)44}4546function App() {47return (48// Don't forget to pass the publishableKey prop49<ClerkProvider publishableKey={clerkPubKey}>50<div className="App">51<header className="App-header">52<img src={logo} className="App-logo" alt="logo" />53<SignedIn>54<Dashboard />55</SignedIn>56<SignedOut>57<RedirectToSignIn />58</SignedOut>59</header>60</div>61</ClerkProvider>62);63}6465export default App;
Clerk's React SDK exports prebuilt components that are very useful. For instance, the SignedIn
component renders its children (in this case, the Dashboard
component) only when a user is signed in. The SignedOut
component does the opposite.
The RedirectToSignIn
component redirects the users to a form where they can choose their authentication method. In this case, the users will be prompted to use their Google account or email address (you set this when creating your Clerk application).
The Dashboard
component makes a request to get post resources using the token provided by the useAuth
hook from the Clerk SDK.
If you go through the sign-up process, you'll end up with something like this:
As you can see, implementing a standard and secure authorization can be difficult, time-consuming, and in some cases, unnecessary. However, you can solve these issues with Clerk, which can help you implement user authentication and authorization with ease.
Clerk does this by providing a range of authentication methods, including social login, password-based, and multifactor authentication. It also gives developers access to a user interface where they can control their applications' authentication and user management settings. Moreover, analytics and reporting capabilities are available to developers to help them keep track of user behavior. Sign up to try Clerk today.
Start completely free for up to 5,000 monthly active users and up to 10 monthly active orgs. No credit card required.
Learn more about our transparent per-user costs to estimate how much your company could save by implementing Clerk.
The latest news and updates from Clerk, sent to your inbox.