react

Authentication in React using Express, Node, Passport and MongoDB

Mar 7th, 2021Abhishek EH12 Min Read

Most of the applications you develop will require authentication of some kind. You can achieve this through social login, third-party authentication like Auth0 or by having an authentication of your own. Having own authentication mechanism has both advantages and disadvantages. The disadvantage is that you need to handle storing hashed passwords, flows like forgot password, reset password, etc. The advantage is that you have more control over the app and you don't have to rely on third-party.

In this tutorial, we will see how we can implement an authentication mechanism of our own. That is using traditional registration and login using username and password.

We will be using:

  • Passport as the middleware for Node.js.
  • MongoDB for storing user details.
  • JWT for identifying the user request.
  • Refresh tokens for renewing the JWT.

Application architecture

This is how our login flow will look like:

Login and Registration Architecture

This is how an authenticated request would look like:

Authenticated Request

Why both JWT and Refresh tokens and What is silent refresh?

You might be already aware when we use JWT for authentication, we do not store the JWT in the back end. We identify the user by retrieving the information from the JWT with the help of the secret used to create the JWT. So there is no way for the server to invalidate a JWT (unless we change JWT secret, which would log out all the users!) or to identify whether the request is coming from a legitimate client or not. Due to this JWTs are created to have a short expiry time (eg: 15 minutes, which might vary depending upon the criticality of the application), so that even if the JWT is stolen, the attacker will not be able to use it for a longer duration.

The problem with having short expiration of JWT is that it would logout the user frequently, which is definitely not a great user experience. To tackle this problem, we introduce refresh tokens, which will have a very long expiration time (say 30 days). Unlike JWT, the refresh token is stored in the cookie which is created in the server (HTTP only, Secure), thus preventing any vulnerable javascript reading the refreshToken cookie (XSS attack).

Refresh tokens are created and are stored in the database during login/registration and set into the cookie. After every short interval (say 5 minutes, which is less than the JWT expiration time), /refreshToken endpoint will be called in the background (Silent Refresh) to renew both the JWT and refreshToken as shown below:

Authenticated Request

Also, since refreshToken is stored in the database, we can invalidate a user session easily by deleting the refresh token or marking it is invalid in the database.

Why JWT is stored in the memory? Why not localStorage?

You might have observed in many blogs and videos informing not to store JWT or any authentication details in local storage or client-side cookies. The reason they might have provided is local storage and client-side cookies are prone to XSS attack. However, storing JWT in the memory does not prevent reading it if there is an XSS vulnerability, it just makes it a bit difficult for the attacker to read it. So the goal must be to develop the application without XSS vulnerability (or any other for that matter) and not to worry about where to store the JWT!

You can read a wonderful article describing why avoiding LocalStorage for tokens is the wrong solution

Why not SameSite cookies?

You might be wondering why not use one SameSite cookies with HTTPOnly and Secure to store the user session? The problem with this approach is:

  • SameSite cookies require both client and server to be in the same domain. So if your client is deployed in a different domain, you will not be able to make use of it.
  • SameSite cookies are not fully implemented in few browsers.

Other advantages of using JWT:

  • Since JWT need not be verified against the database, it improves the performance by avoiding the database call in every request.
  • Since JWT is stored on the client-side, it helps in preventing XSRF/CSRF attacks.

Implementing the server-side

Create a new Node.js project using the following command:

1npm init -y

Open the directory where you have run the above command in your favorite code editor update the package.json file with the start script:

package.json
1{
2 "name": "mern-auth-server",
3 "version": "1.0.0",
4 "description": "",
5 "main": "index.js",
6 "scripts": {
7 "start": "node index.js",
8 "test": "echo \"Error: no test specified\" && exit 1"
9 },
10 "keywords": [],
11 "author": "",
12 "license": "ISC"
13}

Now run the following command to install the packages:

1yarn add express dotenv mongoose body-parser cors cookie-parser
  • express - Used to create a web framework with Node.js.
  • dotenv - Loads environment variables from .env file.
  • mongoose - ODM for mongoDB.
  • body-parser - Parses the request body from the requests like POST request.
  • cors - Enabling CORS policies for the client URL.
  • cookie-parser - To create and read refreshToken cookie.

Now let's create a file named .env in the root directory of the project. This file will have all the configurations and secrets used by the server. You will come across these variables throughout this post.

.env
1JWT_SECRET = jdhdhd-kjfjdhrhrerj-uurhr-jjge
2REFRESH_TOKEN_SECRET = fgkjddshfdjh773bdjsj84-jdjd774
3SESSION_EXPIRY = 60 * 15
4REFRESH_TOKEN_EXPIRY = 60 * 60 * 24 * 30
5MONGO_DB_CONNECTION_STRING = mongodb://127.0.0.1:27017/mern_auth
6COOKIE_SECRET = jhdshhds884hfhhs-ew6dhjd
7WHITELISTED_DOMAINS = http://localhost:3000

.env files with production secrets should not be pushed to the code base. In a deployed environment, set environment variables in the server configuration.

Create a directory named utils and create a file called connectdb.js inside it:

connectdb.js
1const mongoose = require("mongoose")
2const url = process.env.MONGO_DB_CONNECTION_STRING
3const connect = mongoose.connect(url, {
4 useNewUrlParser: true,
5 useUnifiedTopology: true,
6 useCreateIndex: true,
7})
8connect
9 .then(db => {
10 console.log("connected to db")
11 })
12 .catch(err => {
13 console.log(err)
14 })

As the name indicates, it helps in connecting to the MongoDB instance specified in .env file. You may use either a local instance or connect to a cloud provider like MongoDB Atlas.

Now create a file named index.js with the following code:

index.js
1const express = require("express")
2const cors = require("cors")
3const bodyParser = require("body-parser")
4const cookieParser = require("cookie-parser")
5
6if (process.env.NODE_ENV !== "production") {
7 // Load environment variables from .env file in non prod environments
8 require("dotenv").config()
9}
10require("./utils/connectdb")
11
12const app = express()
13
14app.use(bodyParser.json())
15app.use(cookieParser(process.env.COOKIE_SECRET))
16
17//Add the client URL to the CORS policy
18
19const whitelist = process.env.WHITELISTED_DOMAINS
20 ? process.env.WHITELISTED_DOMAINS.split(",")
21 : []
22
23const corsOptions = {
24 origin: function (origin, callback) {
25 if (!origin || whitelist.indexOf(origin) !== -1) {
26 callback(null, true)
27 } else {
28 callback(new Error("Not allowed by CORS"))
29 }
30 },
31
32 credentials: true,
33}
34
35app.use(cors(corsOptions))
36
37app.get("/", function (req, res) {
38 res.send({ status: "success" })
39})
40
41//Start the server in port 8081
42
43const server = app.listen(process.env.PORT || 8081, function () {
44 const port = server.address().port
45
46 console.log("App started at port:", port)
47})

Here we spin up the server at port 8081 and wire up the route / with a success response.

Start the server using the following command (you may use npm start or nodemon):

1yarn start

Now in the console, you should be able to see the following output: yarn start output

If you open the URL http://localhost:8081 in browser or postman, you will see the success response as shown below:

postman success response

Now that we have our basic setup ready, let's dive into the authentication part.

Passport js

Passport is a Node.js middleware used for authentication. It has different strategies written based on the type of authentication we would like to use. It has strategies for local authentication using username and password and also for social logins like Google and Facebook.

Let's install the following packages:

1yarn add passport passport-jwt passport-local passport-local-mongoose jsonwebtoken
  • passport-jwt - Passport strategy to authenticate using JWT for further requests after login/registration.
  • passport-local - Passport strategy for authenticating with a username and password during login & sign up.
  • passport-local-mongoose - Mongoose plugin that helps in building username and password login using passport.
  • jsonwebtoken - Helps in creating and verifying JWT.

Creating the user model

Now let's create the user model. Create a folder named models and a file called user.js inside it with the following code:

user.js
1const mongoose = require("mongoose")
2const Schema = mongoose.Schema
3
4const passportLocalMongoose = require("passport-local-mongoose")
5
6const Session = new Schema({
7 refreshToken: {
8 type: String,
9 default: "",
10 },
11})
12
13const User = new Schema({
14 firstName: {
15 type: String,
16 default: "",
17 },
18 lastName: {
19 type: String,
20 default: "",
21 },
22 authStrategy: {
23 type: String,
24 default: "local",
25 },
26 points: {
27 type: Number,
28 default: 50,
29 },
30 refreshToken: {
31 type: [Session],
32 },
33})
34
35//Remove refreshToken from the response
36User.set("toJSON", {
37 transform: function (doc, ret, options) {
38 delete ret.refreshToken
39 return ret
40 },
41})
42
43User.plugin(passportLocalMongoose)
44
45module.exports = mongoose.model("User", User)

Here we are declaring 2 schemas, one for storing the refresh tokens and another to store user details like the first name, last name, authentication strategy, points (which will be displayed to the user once they log in), and an array of refresh tokens (to support sign in from multiple devices at the same time).

Also, We have removed the refresh token from the toJSON function, so that we don't expose user's refresh tokens whenever we serialize the model and send the data in the API response.

passport-local-mongoose plugin provides functions like authenticate and serializeUser, which we will see in the coming sections.

Creating Local strategy

Now let's create the local strategy, which will be used while login and registration:

Create a folder called strategies and a file named LocalStrategy.js inside it. Local Strategy makes use of the methods provided by passport-local-mongoose for authentication and serializing the user.

LocalStrategy.js
1const passport = require("passport")
2const LocalStrategy = require("passport-local").Strategy
3const User = require("../models/user")
4
5//Called during login/sign up.
6passport.use(new LocalStrategy(User.authenticate()))
7
8//called while after logging in / signing up to set user details in req.user
9passport.serializeUser(User.serializeUser())

Creating JWT Strategy

Similar to the local strategy, create a file named JwtStrategy.js inside the strategies folder with the following code:

JwtStrategy.js
1const passport = require("passport")
2const JwtStrategy = require("passport-jwt").Strategy,
3 ExtractJwt = require("passport-jwt").ExtractJwt
4const User = require("../models/user")
5
6const opts = {}
7opts.jwtFromRequest = ExtractJwt.fromAuthHeaderAsBearerToken()
8opts.secretOrKey = process.env.JWT_SECRET
9
10// Used by the authenticated requests to deserialize the user,
11// i.e., to fetch user details from the JWT.
12passport.use(
13 new JwtStrategy(opts, function (jwt_payload, done) {
14 // Check against the DB only if necessary.
15 // This can be avoided if you don't want to fetch user details in each request.
16 User.findOne({ _id: jwt_payload._id }, function (err, user) {
17 if (err) {
18 return done(err, false)
19 }
20 if (user) {
21 return done(null, user)
22 } else {
23 return done(null, false)
24 // or you could create a new account
25 }
26 })
27 })
28)

As you could see we are using fromAuthHeaderAsBearerToken function, specifying JwtStrategy to extract the JWT from the authentication bearer header. We will see how to pass the JWT in the authentication header in the upcoming sections. You will find other extractors supported by passport here.

Now let's create few functions used for authentication. Create a file called authenticate.js inside the root directory with the following code:

authenticate.js
1const passport = require("passport")
2const jwt = require("jsonwebtoken")
3const dev = process.env.NODE_ENV !== "production"
4
5exports.COOKIE_OPTIONS = {
6 httpOnly: true,
7 // Since localhost is not having https protocol,
8 // secure cookies do not work correctly (in postman)
9 secure: !dev,
10 signed: true,
11 maxAge: eval(process.env.REFRESH_TOKEN_EXPIRY) * 1000,
12 sameSite: "none",
13}
14
15exports.getToken = user => {
16 return jwt.sign(user, process.env.JWT_SECRET, {
17 expiresIn: eval(process.env.SESSION_EXPIRY),
18 })
19}
20
21exports.getRefreshToken = user => {
22 const refreshToken = jwt.sign(user, process.env.REFRESH_TOKEN_SECRET, {
23 expiresIn: eval(process.env.REFRESH_TOKEN_EXPIRY),
24 })
25 return refreshToken
26}
27
28exports.verifyUser = passport.authenticate("jwt", { session: false })
  • COOKIE_OPTIONS is used for creating the refresh token cookie, which should be httpOnly and secure so that it cannot be read by the client javascript. SameSite is set to "None" since client and server will be in different domains.
  • getToken is used to create the JWT.
  • getRefreshToken is used to create the refresh token, which itself is a JWT.
  • verifyUser is a middleware that needs to be called for every authenticated request.

Creating the user router

Create a folder called routes and a file named userRoutes.js inside it with the following code:

userRoutes.js
1const express = require("express")
2const router = express.Router()
3const User = require("../models/user")
4
5const { getToken, COOKIE_OPTIONS, getRefreshToken } = require("../authenticate")
6
7router.post("/signup", (req, res, next) => {
8 // Verify that first name is not empty
9 if (!req.body.firstName) {
10 res.statusCode = 500
11 res.send({
12 name: "FirstNameError",
13 message: "The first name is required",
14 })
15 } else {
16 User.register(
17 new User({ username: req.body.username }),
18 req.body.password,
19 (err, user) => {
20 if (err) {
21 res.statusCode = 500
22 res.send(err)
23 } else {
24 user.firstName = req.body.firstName
25 user.lastName = req.body.lastName || ""
26 const token = getToken({ _id: user._id })
27 const refreshToken = getRefreshToken({ _id: user._id })
28 user.refreshToken.push({ refreshToken })
29 user.save((err, user) => {
30 if (err) {
31 res.statusCode = 500
32 res.send(err)
33 } else {
34 res.cookie("refreshToken", refreshToken, COOKIE_OPTIONS)
35 res.send({ success: true, token })
36 }
37 })
38 }
39 }
40 )
41 }
42})
43
44module.exports = router

Here we are defining the route /signup for registration. It calls the register function from the passport-local-mongoose plugin with username and password and a callback, which will be called once the user is registered.

When the user is successfully registered, we generate the authentication token (JWT) and the refresh token. We save the first name and the last name to the database along with the refresh token. On successfully saving the details to the database, refreshToken cookie is created and the authentication token (JWT) is sent in the response body.

Now, let's include all these files in index.js :

index.js
1const express = require("express")
2const cors = require("cors")
3const bodyParser = require("body-parser")
4const cookieParser = require("cookie-parser")
5const passport = require("passport")
6
7if (process.env.NODE_ENV !== "production") {
8 // Load environment variables from .env file in non prod environments
9 require("dotenv").config()
10}
11require("./utils/connectdb")
12
13require("./strategies/JwtStrategy")
14require("./strategies/LocalStrategy")
15require("./authenticate")
16
17const userRouter = require("./routes/userRoutes")
18
19const app = express()
20
21app.use(bodyParser.json())
22app.use(cookieParser(process.env.COOKIE_SECRET))
23
24//Add the client URL to the CORS policy
25
26const whitelist = process.env.WHITELISTED_DOMAINS
27 ? process.env.WHITELISTED_DOMAINS.split(",")
28 : []
29
30const corsOptions = {
31 origin: function (origin, callback) {
32 if (!origin || whitelist.indexOf(origin) !== -1) {
33 callback(null, true)
34 } else {
35 callback(new Error("Not allowed by CORS"))
36 }
37 },
38
39 credentials: true,
40}
41
42app.use(cors(corsOptions))
43
44app.use(passport.initialize())
45
46app.use("/users", userRouter)
47
48app.get("/", function (req, res) {
49 res.send({ status: "success" })
50})
51
52//Start the server in port 8081
53
54const server = app.listen(process.env.PORT || 8081, function () {
55 const port = server.address().port
56
57 console.log("App started at port:", port)
58})

Testing using postman

Now if you make a post request to the URL: http://localhost:8081/users/signup, with username, password, firstName, and lastName, you should be able to see the token generated in the response.

Postman signup request

If you click on the Cookies, you will be able to see the refreshToken cookie:

Refresh token cookie

If you check the database, you should be able to see the user entry created there:

Database user created

Creating the login route

Now that we have a way to register the user, let's add the login route to userRoutes.js:

userRouter.js
1const express = require("express")
2const router = express.Router()
3const User = require("../models/user")
4const passport = require("passport")
5
6const { getToken, COOKIE_OPTIONS, getRefreshToken } = require("../authenticate")
7
8// ...
9
10router.post("/login", passport.authenticate("local"), (req, res, next) => {
11 const token = getToken({ _id: req.user._id })
12 const refreshToken = getRefreshToken({ _id: req.user._id })
13 User.findById(req.user._id).then(
14 user => {
15 user.refreshToken.push({ refreshToken })
16 user.save((err, user) => {
17 if (err) {
18 res.statusCode = 500
19 res.send(err)
20 } else {
21 res.cookie("refreshToken", refreshToken, COOKIE_OPTIONS)
22 res.send({ success: true, token })
23 }
24 })
25 },
26 err => next(err)
27 )
28})
29
30module.exports = router

Here we are wiring up the local authentication strategy by calling passport.authenticate("local"). Only if the credentials are valid, then the control will come to the body of the login route. If the user is successfully logged in, then we generate the authentication token and refresh token. We save the refresh token to the database and set it in the response cookie. The authentication token (JWT) will be sent in the response body so that the client can attach it to the follow-up request.

If we test it using postman, you will be able to see the response as shown below:

Postman login response

If you have provided invalid credentials, then you would get the response as "Unauthorized" with the response code of 401.

Postman invalid credentials

Creating refreshToken route

We have seen earlier that we would be doing silent refresh by calling /refreshToken endpoint in order to get a new authentication token (JWT). Now let's update userRoutes.js with /refreshToken route:

userRoutes.js
1// ...
2const jwt = require("jsonwebtoken")
3
4//...
5
6router.post("/refreshToken", (req, res, next) => {
7 const { signedCookies = {} } = req
8 const { refreshToken } = signedCookies
9
10 if (refreshToken) {
11 try {
12 const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET)
13 const userId = payload._id
14 User.findOne({ _id: userId }).then(
15 user => {
16 if (user) {
17 // Find the refresh token against the user record in database
18 const tokenIndex = user.refreshToken.findIndex(
19 item => item.refreshToken === refreshToken
20 )
21
22 if (tokenIndex === -1) {
23 res.statusCode = 401
24 res.send("Unauthorized")
25 } else {
26 const token = getToken({ _id: userId })
27 // If the refresh token exists, then create new one and replace it.
28 const newRefreshToken = getRefreshToken({ _id: userId })
29 user.refreshToken[tokenIndex] = { refreshToken: newRefreshToken }
30 user.save((err, user) => {
31 if (err) {
32 res.statusCode = 500
33 res.send(err)
34 } else {
35 res.cookie("refreshToken", newRefreshToken, COOKIE_OPTIONS)
36 res.send({ success: true, token })
37 }
38 })
39 }
40 } else {
41 res.statusCode = 401
42 res.send("Unauthorized")
43 }
44 },
45 err => next(err)
46 )
47 } catch (err) {
48 res.statusCode = 401
49 res.send("Unauthorized")
50 }
51 } else {
52 res.statusCode = 401
53 res.send("Unauthorized")
54 }
55})
56
57//...

Here,

  • We retrieve the refresh token from the signed cookies.
  • We verify the refresh token against the secret used to create the refresh token and extract the payload (which contains the user id) from it.
  • Then we find if the refresh token still exists in the database (in case of logout from all devices, all the refresh tokens belonging to the user will be deleted and the user will be forced to log in again).
  • If it exists in the database, then we replace it with the newly created refresh token.
  • Similar to login & registration steps, here also we will be setting the refresh token in the response cookie and authentication token (JWT) in the response body.

If we test it in postman you will see that new refresh token created each time:

Postman refresh token

Endpoint to fetch user details

Now let's have an endpoint to fetch the logged in user details:

userRouter.js
1// ...
2const {
3 getToken,
4 COOKIE_OPTIONS,
5 getRefreshToken,
6 verifyUser,
7} = require("../authenticate")
8
9// ...
10router.get("/me", verifyUser, (req, res, next) => {
11 res.send(req.user)
12})
13
14// ...

Here we are calling the verifyUser middleware, which in turn will call the JWT strategy to verify the JWT and fetch the user details. If you are not fetching the user details inside the JWT strategy, then you can explicitly fetch it in the body of the route, before sending the user details in the response.

In order to fetch the user details you need to pass the authentication token (JWT) received in the login/sign up response in postman as shown below: Postman authentication header

Creating the logout route

Before moving to the front end, let's create one final route, which will be used to log the user out.

userRouter.js
1// ...
2router.get("/logout", verifyUser, (req, res, next) => {
3 const { signedCookies = {} } = req
4 const { refreshToken } = signedCookies
5 User.findById(req.user._id).then(
6 user => {
7 const tokenIndex = user.refreshToken.findIndex(
8 item => item.refreshToken === refreshToken
9 )
10
11 if (tokenIndex !== -1) {
12 user.refreshToken.id(user.refreshToken[tokenIndex]._id).remove()
13 }
14
15 user.save((err, user) => {
16 if (err) {
17 res.statusCode = 500
18 res.send(err)
19 } else {
20 res.clearCookie("refreshToken", COOKIE_OPTIONS)
21 res.send({ success: true })
22 }
23 })
24 },
25 err => next(err)
26 )
27})
28// ...

Here we are extracting the refresh token cookie and deleting it from the database as well as from the cookie. Deleting of the authentication token (JWT), which is stored in browser memory will happen in front end.

Postman logout

Implementing the front end

Create a new react project using the following command

1npx create-react-app mern-auth-client

Adding BlueprintJS for styling

We will be making use of BlueprintJS in order to style the app. So let's install it:

1yarn add @blueprintjs/core

In the index.css file, let's include the css files related to BlueprintJS:

index.css
1@import "~normalize.css";
2@import "~@blueprintjs/core/lib/css/blueprint.css";
3@import "~@blueprintjs/icons/lib/css/blueprint-icons.css";
4body {
5 margin: 0 auto;
6 max-width: 400px;
7}

Also, note that we have added styles to center align the page contents.

Creating login and registration components

Now let's create Login and Registration components using BlueprintJS:

Login.js
1import { Button, FormGroup, InputGroup } from "@blueprintjs/core"
2import React, { useState } from "react"
3
4const Login = () => {
5 const [email, setEmail] = useState("")
6 const [password, setPassword] = useState("")
7
8 return (
9 <>
10 <form className="auth-form">
11 <FormGroup label="Email" labelFor="email">
12 <InputGroup
13 id="email"
14 placeholder="Email"
15 type="email"
16 value={email}
17 onChange={e => setEmail(e.target.value)}
18 />
19 </FormGroup>
20 <FormGroup label="Password" labelFor="password">
21 <InputGroup
22 id="password"
23 placeholder="Password"
24 type="password"
25 value={password}
26 onChange={e => setPassword(e.target.value)}
27 />
28 </FormGroup>
29 <Button intent="primary" fill type="submit" text="Sign In" />
30 </form>
31 </>
32 )
33}
34
35export default Login

Here we are having username and password fields and a submit button. We are making use of local states to store the value of email and password and we have wired them to the on change handlers.

Let's add some margin for the form:

index.css
1@import "~normalize.css";
2@import "~@blueprintjs/core/lib/css/blueprint.css";
3@import "~@blueprintjs/icons/lib/css/blueprint-icons.css";
4body {
5 margin: 0 auto;
6 max-width: 400px;
7}
8
9.auth-form {
10 margin-top: 10px;
11}

Similarly, let's create the registration page:

Register.js
1import { Button, FormGroup, InputGroup } from "@blueprintjs/core"
2import React, { useState } from "react"
3
4const Register = () => {
5 const [firstName, setFirstName] = useState("")
6 const [lastName, setLastName] = useState("")
7 const [email, setEmail] = useState("")
8 const [password, setPassword] = useState("")
9
10 return (
11 <>
12 <form className="auth-form">
13 <FormGroup label="First Name" labelFor="firstName">
14 <InputGroup
15 id="firstName"
16 placeholder="First Name"
17 onChange={e => setFirstName(e.target.value)}
18 value={firstName}
19 />
20 </FormGroup>
21 <FormGroup label="Last Name" labelFor="firstName">
22 <InputGroup
23 id="lastName"
24 placeholder="Last Name"
25 onChange={e => setLastName(e.target.value)}
26 value={lastName}
27 />
28 </FormGroup>
29 <FormGroup label="Email" labelFor="email">
30 <InputGroup
31 id="email"
32 type="email"
33 placeholder="Email"
34 onChange={e => setEmail(e.target.value)}
35 value={email}
36 />
37 </FormGroup>
38 <FormGroup label="Password" labelFor="password">
39 <InputGroup
40 id="password"
41 placeholder="Password"
42 type="password"
43 onChange={e => setPassword(e.target.value)}
44 value={password}
45 />
46 </FormGroup>
47 <Button intent="primary" text="Register" fill type="submit" />
48 </form>
49 </>
50 )
51}
52
53export default Register

Now in the App.js, let's include both Login and Register components:

App.js
1import { Card, Tab, Tabs } from "@blueprintjs/core"
2import { useState } from "react"
3import Login from "./Login"
4import Register from "./Register"
5
6function App() {
7 const [currentTab, setCurrentTab] = useState("login")
8
9 return (
10 <Card elevation="1">
11 <Tabs id="Tabs" onChange={setCurrentTab} selectedTabId={currentTab}>
12 <Tab id="login" title="Login" panel={<Login />} />
13 <Tab id="register" title="Register" panel={<Register />} />
14 <Tabs.Expander />
15 </Tabs>
16 </Card>
17 )
18}
19
20export default App

Here we are making use of the Tabs component from BlueprintJS along with the local react state to determine the currently active tab.

If you open http://localhost:3000/ in your browser you should be able to switch between login and registration forms:

Login and Registration form

Creating the user context

Before we submit the login and registration forms, let's create a custom react context to store the user token and details. In my previous post, I've explained how to create and use the react context for state management.

Create a folder named context and a file called UserContext.js inside it:

UserContext.js
1import React, { useState } from "react"
2
3const UserContext = React.createContext([{}, () => {}])
4
5let initialState = {}
6
7const UserProvider = props => {
8 const [state, setState] = useState(initialState)
9
10 return (
11 <UserContext.Provider value={[state, setState]}>
12 {props.children}
13 </UserContext.Provider>
14 )
15}
16
17export { UserContext, UserProvider }

Add the provider to index.js:

index.js
1import React from "react"
2import ReactDOM from "react-dom"
3import "./index.css"
4import App from "./App"
5import { UserProvider } from "./context/UserContext"
6
7ReactDOM.render(
8 <React.StrictMode>
9 <UserProvider>
10 <App />
11 </UserProvider>
12 </React.StrictMode>,
13 document.getElementById("root")
14)

Submitting the login and registration forms:

Now let's bind a submit handler to our login form:

Login.js
1import { Button, Callout, FormGroup, InputGroup } from "@blueprintjs/core"
2import React, { useContext, useState } from "react"
3import { UserContext } from "./context/UserContext"
4
5const Login = () => {
6 const [isSubmitting, setIsSubmitting] = useState(false)
7 const [error, setError] = useState("")
8 const [email, setEmail] = useState("")
9 const [password, setPassword] = useState("")
10 const [userContext, setUserContext] = useContext(UserContext)
11
12 const formSubmitHandler = e => {
13 e.preventDefault()
14 setIsSubmitting(true)
15 setError("")
16
17 const genericErrorMessage = "Something went wrong! Please try again later."
18
19 fetch(process.env.REACT_APP_API_ENDPOINT + "users/login", {
20 method: "POST",
21 credentials: "include",
22 headers: { "Content-Type": "application/json" },
23 body: JSON.stringify({ username: email, password }),
24 })
25 .then(async response => {
26 setIsSubmitting(false)
27 if (!response.ok) {
28 if (response.status === 400) {
29 setError("Please fill all the fields correctly!")
30 } else if (response.status === 401) {
31 setError("Invalid email and password combination.")
32 } else {
33 setError(genericErrorMessage)
34 }
35 } else {
36 const data = await response.json()
37 setUserContext(oldValues => {
38 return { ...oldValues, token: data.token }
39 })
40 }
41 })
42 .catch(error => {
43 setIsSubmitting(false)
44 setError(genericErrorMessage)
45 })
46 }
47 return (
48 <>
49 {error && <Callout intent="danger">{error}</Callout>}
50 <form onSubmit={formSubmitHandler} className="auth-form">
51 <FormGroup label="Email" labelFor="email">
52 <InputGroup
53 id="email"
54 placeholder="Email"
55 type="email"
56 value={email}
57 onChange={e => setEmail(e.target.value)}
58 />
59 </FormGroup>
60 <FormGroup label="Password" labelFor="password">
61 <InputGroup
62 id="password"
63 placeholder="Password"
64 type="password"
65 value={password}
66 onChange={e => setPassword(e.target.value)}
67 />
68 </FormGroup>
69 <Button
70 intent="primary"
71 disabled={isSubmitting}
72 text={`${isSubmitting ? "Signing In" : "Sign In"}`}
73 fill
74 type="submit"
75 />
76 </form>
77 </>
78 )
79}
80
81export default Login

In the above code,

  • We have introduced two additional local states.
  • First, the isSubmitting is used for disabling the sign-in button when the user has already pressed it. It is also used to display the text "Signing In" in order to inform the user of what is happening.
  • We have an error state, which is used to display an appropriate error message to the user in case of login fails.
  • In formSubmitHandler, we are disabling the default submission of the form using e.preventDefault().
  • We are making a POST call to the endpoint /users/login, created earlier in the server project with the username and password parameters in the request body.
  • In case of any errors, we are setting an appropriate error message using setError function.
  • On successful login, we will be saving the token value to the user context.

We are not doing any client-side validation of username and password considering the scope of the post. I have written an article on how to do form validation in react.

Don't forget to add the environment variable REACT_APP_API_ENDPOINT to .env file and restart the client project. It will have the server URL as its value:

.env
1REACT_APP_API_ENDPOINT = http://localhost:8081/

If you are having multiple environments like dev, qa, stage, prod etc, and want to see how you can set multiple environment variables you can read this article.

Now if you click on login with valid credentials and log the userContext in Login.js to the console, you should be able to see the token:

Token logged to console

Similarly, add the submit handler to the registration form:

Register.js
1import { Button, Callout, FormGroup, InputGroup } from "@blueprintjs/core"
2import React, { useContext, useState } from "react"
3import { UserContext } from "./context/UserContext"
4
5const Register = () => {
6 const [isSubmitting, setIsSubmitting] = useState(false)
7 const [error, setError] = useState("")
8 const [firstName, setFirstName] = useState("")
9 const [lastName, setLastName] = useState("")
10 const [email, setEmail] = useState("")
11 const [password, setPassword] = useState("")
12 const [userContext, setUserContext] = useContext(UserContext)
13
14 const formSubmitHandler = e => {
15 e.preventDefault()
16 setIsSubmitting(true)
17 setError("")
18
19 const genericErrorMessage = "Something went wrong! Please try again later."
20
21 fetch(process.env.REACT_APP_API_ENDPOINT + "users/signup", {
22 method: "POST",
23 credentials: "include",
24 headers: { "Content-Type": "application/json" },
25 body: JSON.stringify({ firstName, lastName, username: email, password }),
26 })
27 .then(async response => {
28 setIsSubmitting(false)
29 if (!response.ok) {
30 if (response.status === 400) {
31 setError("Please fill all the fields correctly!")
32 } else if (response.status === 401) {
33 setError("Invalid email and password combination.")
34 } else if (response.status === 500) {
35 console.log(response)
36 const data = await response.json()
37 if (data.message) setError(data.message || genericErrorMessage)
38 } else {
39 setError(genericErrorMessage)
40 }
41 } else {
42 const data = await response.json()
43 setUserContext(oldValues => {
44 return { ...oldValues, token: data.token }
45 })
46 }
47 })
48 .catch(error => {
49 setIsSubmitting(false)
50 setError(genericErrorMessage)
51 })
52 }
53
54 return (
55 <>
56 {error && <Callout intent="danger">{error}</Callout>}
57
58 <form onSubmit={formSubmitHandler} className="auth-form">
59 <FormGroup label="First Name" labelFor="firstName">
60 <InputGroup
61 id="firstName"
62 placeholder="First Name"
63 onChange={e => setFirstName(e.target.value)}
64 value={firstName}
65 />
66 </FormGroup>
67 <FormGroup label="Last Name" labelFor="firstName">
68 <InputGroup
69 id="lastName"
70 placeholder="Last Name"
71 onChange={e => setLastName(e.target.value)}
72 value={lastName}
73 />
74 </FormGroup>
75 <FormGroup label="Email" labelFor="email">
76 <InputGroup
77 id="email"
78 type="email"
79 placeholder="Email"
80 onChange={e => setEmail(e.target.value)}
81 value={email}
82 />
83 </FormGroup>
84 <FormGroup label="Password" labelFor="password">
85 <InputGroup
86 id="password"
87 placeholder="Password"
88 type="password"
89 onChange={e => setPassword(e.target.value)}
90 value={password}
91 />
92 </FormGroup>
93 <Button
94 intent="primary"
95 disabled={isSubmitting}
96 text={`${isSubmitting ? "Registering" : "Register"}`}
97 fill
98 type="submit"
99 />
100 </form>
101 </>
102 )
103}
104
105export default Register

Showing welcome screen

Now that we have the token stored in the context, we can determine whether to show the Login/Register form or the welcome screen.

First, let's create the welcome component:

Welcome.js
1import React from "react"
2
3const Welcome = () => {
4 return <div>Welcome!</div>
5}
6
7export default Welcome

In the App.js let's check if userContext.token has any value, if so then we will show the welcome screen otherwise we will show the Login/Register screen:

App.js
1import { Card, Tab, Tabs } from "@blueprintjs/core"
2import { useContext, useState } from "react"
3import { UserContext } from "./context/UserContext"
4import Login from "./Login"
5import Register from "./Register"
6import Welcome from "./Welcome"
7
8function App() {
9 const [currentTab, setCurrentTab] = useState("login")
10 const [userContext, setUserContext] = useContext(UserContext)
11
12 return !userContext.token ? (
13 <Card elevation="1">
14 <Tabs id="Tabs" onChange={setCurrentTab} selectedTabId={currentTab}>
15 <Tab id="login" title="Login" panel={<Login />} />
16 <Tab id="register" title="Register" panel={<Register />} />
17 <Tabs.Expander />
18 </Tabs>
19 </Card>
20 ) : (
21 <Welcome />
22 )
23}
24
25export default App

Even though the login/registration works fine, each time we refresh the page, it will show the login screen. To handle this we will call the refresh token endpoint when the page loads and fetch the authentication token:

App.js
1import { Card, Tab, Tabs } from "@blueprintjs/core"
2import { useCallback, useContext, useEffect, useState } from "react"
3import { UserContext } from "./context/UserContext"
4import Loader from "./Loader"
5import Login from "./Login"
6import Register from "./Register"
7import Welcome from "./Welcome"
8
9function App() {
10 const [currentTab, setCurrentTab] = useState("login")
11 const [userContext, setUserContext] = useContext(UserContext)
12
13 const verifyUser = useCallback(() => {
14 fetch(process.env.REACT_APP_API_ENDPOINT + "users/refreshToken", {
15 method: "POST",
16 credentials: "include",
17 headers: { "Content-Type": "application/json" },
18 }).then(async response => {
19 if (response.ok) {
20 const data = await response.json()
21 setUserContext(oldValues => {
22 return { ...oldValues, token: data.token }
23 })
24 } else {
25 setUserContext(oldValues => {
26 return { ...oldValues, token: null }
27 })
28 }
29 // call refreshToken every 5 minutes to renew the authentication token.
30 setTimeout(verifyUser, 5 * 60 * 1000)
31 })
32 }, [setUserContext])
33
34 useEffect(() => {
35 verifyUser()
36 }, [verifyUser])
37
38 return userContext.token === null ? (
39 <Card elevation="1">
40 <Tabs id="Tabs" onChange={setCurrentTab} selectedTabId={currentTab}>
41 <Tab id="login" title="Login" panel={<Login />} />
42 <Tab id="register" title="Register" panel={<Register />} />
43 <Tabs.Expander />
44 </Tabs>
45 </Card>
46 ) : userContext.token ? (
47 <Welcome />
48 ) : (
49 <Loader />
50 )
51}
52
53export default App

Here we have declared a function called verifyUser (enclosed within useCallback to avoid re-declaration when component re-renders), which will be called on page load (with the help of useEffect) and will make a call to /refreshToken endpoint. If it receives a success response, then it saves the token from the response body to the context. If we receive, any error in refresh token call then we set the token to null in the context. This means, whenever the user is not authenticated, the token will be set to null and we will show the Login screen.

When the refresh token call being made, we will display a spinner by including the Loader component. Here is the code for the Loader.js:

Loader.js
1import { Spinner } from "@blueprintjs/core"
2import React from "react"
3
4const Loader = () => {
5 return (
6 <div className="loader">
7 <Spinner size={50} />
8 </div>
9 )
10}
11
12export default Loader

Let's also make sure that the spinner comes in the center by adding some styles:

index.css
1/* ... */
2.loader {
3 margin: 100px auto;
4}

You will see that in App.js, we are calling verifyUser function every 5 minutes. This is the silent refresh in action, used to renew the authentication token, which has an expiry time of 15 minutes.

Silent refresh

Fetching the user details

Now that we have the login and refresh token in place, let's fetch the user details in the welcome screen:

Welcome.js
1import { Button, Card } from "@blueprintjs/core"
2import React, { useCallback, useContext, useEffect } from "react"
3import { UserContext } from "./context/UserContext"
4import Loader from "./Loader"
5
6const Welcome = () => {
7 const [userContext, setUserContext] = useContext(UserContext)
8
9 const fetchUserDetails = useCallback(() => {
10 fetch(process.env.REACT_APP_API_ENDPOINT + "users/me", {
11 method: "GET",
12 credentials: "include",
13 // Pass authentication token as bearer token in header
14 headers: {
15 "Content-Type": "application/json",
16 Authorization: `Bearer ${userContext.token}`,
17 },
18 }).then(async response => {
19 if (response.ok) {
20 const data = await response.json()
21 setUserContext(oldValues => {
22 return { ...oldValues, details: data }
23 })
24 } else {
25 if (response.status === 401) {
26 // Edge case: when the token has expired.
27 // This could happen if the refreshToken calls have failed due to network error or
28 // User has had the tab open from previous day and tries to click on the Fetch button
29 window.location.reload()
30 } else {
31 setUserContext(oldValues => {
32 return { ...oldValues, details: null }
33 })
34 }
35 }
36 })
37 }, [setUserContext, userContext.token])
38
39 useEffect(() => {
40 // fetch only when user details are not present
41 if (!userContext.details) {
42 fetchUserDetails()
43 }
44 }, [userContext.details, fetchUserDetails])
45
46 const refetchHandler = () => {
47 // set details to undefined so that spinner will be displayed and
48 // fetchUserDetails will be invoked from useEffect
49 setUserContext(oldValues => {
50 return { ...oldValues, details: undefined }
51 })
52 }
53
54 return userContext.details === null ? (
55 "Error Loading User details"
56 ) : !userContext.details ? (
57 <Loader />
58 ) : (
59 <Card elevation="1">
60 <div className="user-details">
61 <div>
62 <p>
63 Welcome&nbsp;
64 <strong>
65 {userContext.details.firstName}
66 {userContext.details.lastName &&
67 " " + userContext.details.lastName}
68 </strong>!
69 </p>
70 <p>
71 Your reward points: <strong>{userContext.details.points}</strong>
72 </p>
73 </div>
74 <div className="user-actions">
75 <Button text="Refetch" intent="primary" onClick={refetchHandler} />
76 </div>
77 </div>
78 </Card>
79 )
80}
81
82export default Welcome

Here we have the fetchUserDetails, which will be called from the useEffect hook, whenever the userContext.details does not have any value. In the fetchUserDetails function, we are calling the /users/me endpoint by passing the authentication token in the header. The response of the call will be saved in userContext.details. If there is any error in getting the response, then we set userContext.details to null, so that we can show some error message to the user.

When the user details are being fetched, we display the loader and when details are successfully fetched, we display them to the user. We also have a refetch button, when clicked will clear the user details from the user context, which will trigger the useEffect hook to re-fetch the user details, and thus serving its purpose.

Logout functionality

Now that we are displaying the user details, let's add an option to logout:

Welcome.js
1import { Button, Card } from "@blueprintjs/core"
2import React, { useCallback, useContext, useEffect } from "react"
3import { UserContext } from "./context/UserContext"
4import Loader from "./Loader"
5
6const Welcome = () => {
7 const [userContext, setUserContext] = useContext(UserContext)
8
9 const fetchUserDetails = useCallback(() => {
10 fetch(process.env.REACT_APP_API_ENDPOINT + "users/me", {
11 method: "GET",
12 credentials: "include",
13 // Pass authentication token as bearer token in header
14 headers: {
15 "Content-Type": "application/json",
16 Authorization: `Bearer ${userContext.token}`,
17 },
18 }).then(async response => {
19 if (response.ok) {
20 const data = await response.json()
21 setUserContext(oldValues => {
22 return { ...oldValues, details: data }
23 })
24 } else {
25 if (response.status === 401) {
26 // Edge case: when the token has expired.
27 // This could happen if the refreshToken calls have failed due to network error or
28 // User has had the tab open from previous day and tries to click on the Fetch button
29 window.location.reload()
30 } else {
31 setUserContext(oldValues => {
32 return { ...oldValues, details: null }
33 })
34 }
35 }
36 })
37 }, [setUserContext, userContext.token])
38
39 useEffect(() => {
40 // fetch only when user details are not present
41 if (!userContext.details) {
42 fetchUserDetails()
43 }
44 }, [userContext.details, fetchUserDetails])
45
46 const logoutHandler = () => {
47 fetch(process.env.REACT_APP_API_ENDPOINT + "users/logout", {
48 credentials: "include",
49 headers: {
50 "Content-Type": "application/json",
51 Authorization: `Bearer ${userContext.token}`,
52 },
53 }).then(async response => {
54 setUserContext(oldValues => {
55 return { ...oldValues, details: undefined, token: null }
56 })
57 window.localStorage.setItem("logout", Date.now())
58 })
59 }
60
61 const refetchHandler = () => {
62 // set details to undefined so that spinner will be displayed and
63 // fetchUserDetails will be invoked from useEffect
64 setUserContext(oldValues => {
65 return { ...oldValues, details: undefined }
66 })
67 }
68
69 return userContext.details === null ? (
70 "Error Loading User details"
71 ) : !userContext.details ? (
72 <Loader />
73 ) : (
74 <Card elevation="1">
75 <div className="user-details">
76 <div>
77 <p>
78 Welcome&nbsp;
79 <strong>
80 {userContext.details.firstName}
81 {userContext.details.lastName &&
82 " " + userContext.details.lastName}
83 </strong>!
84 </p>
85 <p>
86 Your reward points: <strong>{userContext.details.points}</strong>
87 </p>
88 </div>
89 <div className="user-actions">
90 <Button
91 text="Logout"
92 onClick={logoutHandler}
93 minimal
94 intent="primary"
95 />
96 <Button text="Refetch" intent="primary" onClick={refetchHandler} />
97 </div>
98 </div>
99 </Card>
100 )
101}
102
103export default Welcome

When the logout button is clicked, we are calling logoutHandler function. In logoutHandler we are calling /users/logout endpoint, so that the refresh token will be removed from the database and the cookie. Finally, we are setting the user details and token in the context to null so that the login page will be displayed.

Also, we are saving the logout time to the local storage, which will be used in the next section for logging the user out from all the tabs.

Let's also add some styling to the welcome screen:

index.css
1/* ... */
2.user-details {
3 display: flex;
4 justify-content: space-between;
5}
6.user-actions {
7 display: flex;
8 flex-direction: column;
9}

Now the welcome screen should look like this:

Welcome Screen

Logging out from multiple tabs

If the user is logged in on multiple tabs, then we can log them out by adding an event listener to the local storage (we are saving the logout time to the local storage while logging out for this purpose).

1import { Card, Tab, Tabs } from "@blueprintjs/core"
2import { useCallback, useContext, useEffect, useState } from "react"
3import { UserContext } from "./context/UserContext"
4import Loader from "./Loader"
5import Login from "./Login"
6import Register from "./Register"
7import Welcome from "./Welcome"
8
9function App() {
10 const [currentTab, setCurrentTab] = useState("login")
11 const [userContext, setUserContext] = useContext(UserContext)
12
13 const verifyUser = useCallback(() => {
14 fetch(process.env.REACT_APP_API_ENDPOINT + "users/refreshToken", {
15 method: "POST",
16 credentials: "include",
17 headers: { "Content-Type": "application/json" },
18 }).then(async response => {
19 if (response.ok) {
20 const data = await response.json()
21 setUserContext(oldValues => {
22 return { ...oldValues, token: data.token }
23 })
24 } else {
25 setUserContext(oldValues => {
26 return { ...oldValues, token: null }
27 })
28 }
29 // call refreshToken every 5 minutes to renew the authentication token.
30 setTimeout(verifyUser, 5 * 60 * 1000)
31 })
32 }, [setUserContext])
33
34 useEffect(() => {
35 verifyUser()
36 }, [verifyUser])
37
38 /**
39 * Sync logout across tabs
40 */
41 const syncLogout = useCallback(event => {
42 if (event.key === "logout") {
43 // If using react-router-dom, you may call history.push("/")
44 window.location.reload()
45 }
46 }, [])
47
48 useEffect(() => {
49 window.addEventListener("storage", syncLogout)
50 return () => {
51 window.removeEventListener("storage", syncLogout)
52 }
53 }, [syncLogout])
54
55 return userContext.token === null ? (
56 <Card elevation="1">
57 <Tabs id="Tabs" onChange={setCurrentTab} selectedTabId={currentTab}>
58 <Tab id="login" title="Login" panel={<Login />} />
59 <Tab id="register" title="Register" panel={<Register />} />
60 <Tabs.Expander />
61 </Tabs>
62 </Card>
63 ) : userContext.token ? (
64 <Welcome />
65 ) : (
66 <Loader />
67 )
68}
69
70export default App

So whenever you click on the logout button in one tab, all the other tabs reload and since the refresh token is deleted from the cookie, the refresh token call fails and the login page is displayed.

Source code and Demo

You can view the complete source code of the client here, the server here, and a demo here.

Leave a Comment

Comments

JustinJune 2, 2021 at 10:27 AM
great article - thanks very much for sharing, I worked my way through it with no problems at all and am looking forward to your other guides. One thing though, the code highlights from the Logout Functionality (handleLogout and the Button) are out of alignment. Thanks again :)
Abhishek EHJune 2, 2021 at 11:33 AM
Glad you liked the article. Thanks for letting me know about the highlight issue. I have fixed it :)
BenjiJune 3, 2021 at 10:57 AM
Great tutorial but logout doesn't work
Abhishek EHJune 3, 2021 at 4:36 PM
Thanks a lot for reporting the issue. The actual issue was happening since the SameSite flag was not set to "None" the cookie was not being passed by chrome and both refresh token and logout were failing. When I had written the article, I had tested only in Firefox (where is the issue does not seem to happen). Have fixed the issue now and added a safety check in logout route as well!
BenjiJune 17, 2021 at 1:09 PM
Works great now!
AdeelJuly 1, 2021 at 11:40 AM
Excellent Explanation, however the token is null when console.log(userContext) in Login.js file, although it is displayed if I console.log(data.token) but not in token for userContext. which is causing automatic logout of the user after 5 minutes timer.
Abhishek EHJuly 4, 2021 at 5:55 AM
I have logged in and kept the the app (https://trial-mern-auth-client.vercel.app/) open for 30 minutes and I did not get logged out. I refreshed the page and checked, I was still logged in. If possible, could you please share your code, so that I can have a look?
© 2021 CodingDeft.Com