Securing React Apps: Best Practices for Handling Sensitive Data

ยท

7 min read

When dealing with sensitive data, such as JWT tokens, in React applications, it's crucial to implement best practices to ensure the security of your application.

  1. HTTP Cookies:

    • Store JWT tokens in HTTP-only cookies. This prevents client-side JavaScript from accessing the token, reducing the risk of cross-site scripting (XSS) attacks.

    • Make sure to configure the server to include the HttpOnly flag when setting the cookie.

  2. Secure and HttpOnly Flags:

    • When setting cookies, use the Secure flag to ensure that the cookie is only sent over HTTPS connections.

    • Combine the HttpOnly flag with cookies to prevent client-side access through JavaScript.

  3. Local Storage and Session Storage:

    • Avoid storing sensitive data like JWT tokens directly in localStorage or sessionStorage due to potential security vulnerabilities. These storage options are accessible through JavaScript, making them susceptible to XSS attacks.
  4. React Context API:

    • Use the React Context API to manage global state within your React application. However, avoid storing sensitive data directly in the context, and consider using a state management library like Redux for better control and security.
  5. Redux or MobX:

    • If you're using Redux or MobX for state management, you can store sensitive data in the store. Ensure that you follow best practices, and consider using middleware to handle token storage securely.
  6. Encrypted Local Storage:

    • If you need to store sensitive data client-side, consider encrypting the data before storing it. There are libraries available that can help with client-side encryption, such as crypto-js.
  7. Session Cookies:

    • Use session cookies for temporary storage of sensitive information. Session cookies are stored in memory and are cleared when the browser is closed, reducing the risk of data exposure.
  8. Secure Server Storage:

    • If possible, consider storing sensitive data on the server side and only sending the necessary information to the client as needed. This reduces the exposure of sensitive data on the client side.

In this tutorial, we'll explore using HTTP cookies as a secure means to transfer JWT tokens for authentication and authorization in a React app. Here's a step-by-step guide:

Project Setup

We begin by creating a new Next.js app and installing essential packages for UI components and secure token handling:

npx create-react-app@latest make-secure-cookie-app
cd make-secure-cookie-app
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
npm install jsonwebtoken react-hook-form

Project Structure and Constants

Our file structure is as follows

Refer package.json below

//package.json
{
  "name": "make-secure-cookie-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@emotion/react": "^11.11.1",
    "@emotion/styled": "^11.11.0",
    "@mui/icons-material": "^5.14.18",
    "@mui/material": "^5.14.18",
    "jsonwebtoken": "^9.0.2",
    "next": "14.0.2",
    "react": "^18",
    "react-dom": "^18",
    "react-hook-form": "^7.48.2"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "14.0.2",
    "typescript": "^5"
  }
}

Create a .env file to store sensitive data and a Constants.ts file for app constants:

.env:

//.env
APP_SECRET=thisisloveyoucantescape
environment=dev

Constants.ts:

//src/utilities/Constants.ts
export const APP_CONSTANTS = {
  ACCESS_TOKEN_NAME: "access-token",
  REGISTERED_USERS: [{ username: "user1", password: "myhashedpass1" }],
};

Authentication API

Implement an API endpoint for user login that generates a JWT token and sets it as an HTTP-only cookie.

//src/pages/api/login.ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { APP_CONSTANTS } from "@/utilities/Constants";
import type { NextApiRequest, NextApiResponse } from "next";
const jwt = require("jsonwebtoken");

type Data = {
  message: string;
  name?: string;
};

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  if (req.method?.toLowerCase() !== "post")
    return res.status(403).json({ message: "Invalid Request Method" });

  const { username, password } = req.body;
  const user = APP_CONSTANTS.REGISTERED_USERS.find((item) => {
    return item.username === username;
  });
  //logged in success then set access-token in cookie for authentication and authorization
  if (!!user && user.password === password) {
    const jwtTokenValue = jwt.sign(
      {
        exp: Math.floor(Date.now() / 1000) + 60 * 60,
        data: {
          username,
          name: "Devsaz",
          age: 25,
        },
      },
      process.env.APP_SECRET
    );
    res.setHeader(
      "Set-Cookie",
      `${APP_CONSTANTS.ACCESS_TOKEN_NAME}=${jwtTokenValue}; Path=/; httpOnly; ${
        process.env.environment === "production" ? "Secure" : ""
      }`
    );
    return res.status(200).json({ message: "Success" });
  }
  return res.status(403).json({ message: "Unauthenticated" });
}

List API

Create an API endpoint to fetch content only if the user is authenticated and authorized. We use the httpOnly secure cookie to verify the access token for authorization.

//src/pages/api/list.ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { APP_CONSTANTS } from "@/utilities/Constants";
import type { NextApiRequest, NextApiResponse } from "next";
const jwt = require("jsonwebtoken");

type Data = {
  message: string;
  name?: string;
  data?: { description: string; image: string; title: string }[];
};

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  jwt.verify(
    req.cookies[APP_CONSTANTS.ACCESS_TOKEN_NAME],
    process.env.APP_SECRET,
    function (err: any, decoded: any) {
      console.log("err", err);
      if (err) {
        return res.status(403).json({ message: "Unauthorized" });
      }
    }
  );
  return res.status(200).json({
    message: "Success",
    data: [
      {
        image: "https://mui.com/static/images/cards/contemplative-reptile.jpg",
        title: "Lizard",
        description: `Lizards are a widespread group of squamate reptiles, with over 6,000
        species, ranging across all continents except Antarctica`,
      },
    ],
  });
}

Logout API

Implement an API endpoint to clear the JWT token cookie, effectively logging the user out.

//src/pages/api/clearcookie.ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { APP_CONSTANTS } from "@/utilities/Constants";
import type { NextApiRequest, NextApiResponse } from "next";
const jwt = require("jsonwebtoken");

type Data = {
  message: string;
  name?: string;
};

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  res.setHeader(
    "Set-Cookie",
    `${APP_CONSTANTS.ACCESS_TOKEN_NAME}="; Path=/; httpOnly; ${
      process.env.environment === "production" ? "Secure" : ""
    }; Max-Age=0`
  );
  return res.status(200).json({ message: "Success" });
}

Login Page

Design a simple login page using React Hook Form.

//src/pages/index.tsx
import { useForm, SubmitHandler } from "react-hook-form";
import FormControl from "@mui/material/FormControl";
import FormHelperText from "@mui/material/FormHelperText";
import Input from "@mui/material/Input";
import InputLabel from "@mui/material/InputLabel";
import React from "react";
import Grid from "@mui/material/Grid";
import { styled } from "@mui/material/styles";
import Paper from "@mui/material/Paper";
import Button from "@mui/material/Button";
import SendIcon from "@mui/icons-material/Send";
import { useRouter } from "next/navigation";

type Inputs = {
  username: string;
  password: string;
};

const Item = styled(Paper)(({ theme }) => ({
  backgroundColor: theme.palette.mode === "dark" ? "#1A2027" : "#fff",
  ...theme.typography.body2,
  padding: theme.spacing(1),
  textAlign: "center",
  color: theme.palette.text.secondary,
}));

export default function Home() {
  const router = useRouter();
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<Inputs>();
  const onSubmit: SubmitHandler<Inputs> = async (data) => {
    const response = await fetch("/api/login", {
      method: "POST", // *GET, POST, PUT, DELETE, etc.
      headers: {
        "Content-Type": "application/json",
        // 'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: JSON.stringify(data), // body data type must match "Content-Type" header
    });
    await response
      .json()
      .then((da) => {
        alert(da?.message);
      })
      .catch((err) => {
        (async () => {
          await fetch("/api/clearcookie");
        })();
        alert(err?.message || "Error");
      });
    switch (true) {
      case response.status >= 400 && response.status <= 599:
        (async () => {
          await fetch("/api/clearcookie");
        })();
        return;
      case response.status === 200:
        router.push("/list");
      default:
        return;
    }
  };

  return (
    /* "handleSubmit" will validate your inputs before invoking "onSubmit" */
    <form id="sampleForm" onSubmit={handleSubmit(onSubmit)}>
      <Grid container rowSpacing={1} columnSpacing={{ xs: 1, sm: 2, md: 3 }}>
        <Grid item xs={6}>
          <Item>
            <FormControl error={!!errors.username} variant="standard">
              <InputLabel htmlFor="component-name">Name</InputLabel>
              <Input
                {...register("username", { required: "Name is required" })}
                id="component-name"
                defaultValue=""
              />
              {errors.username && (
                <FormHelperText id="component-name-error-text">
                  {`${errors.username.message}`}
                </FormHelperText>
              )}
            </FormControl>
          </Item>
        </Grid>
        <Grid item xs={6}>
          <Item>
            <FormControl error={!!errors.password} variant="standard">
              <InputLabel htmlFor="component-name">Password</InputLabel>
              <Input
                {...register("password", { required: "Password is required" })}
                id="component-name"
                defaultValue=""
              />
              {errors.password && (
                <FormHelperText id="component-name-error-text">
                  {`${errors.password.message}`}
                </FormHelperText>
              )}
            </FormControl>
          </Item>
        </Grid>
        <Grid item xs={12}>
          <Item>
            <Button type="submit" variant="contained" endIcon={<SendIcon />}>
              Submit
            </Button>
          </Item>
        </Grid>
      </Grid>
    </form>
  );
}

List Page

Create a page to display content only accessible to authenticated and authorized users.

//src/pages/list.tsx
import * as React from "react";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import CardMedia from "@mui/material/CardMedia";
import Typography from "@mui/material/Typography";
import { CardActionArea } from "@mui/material";
import { useRouter } from "next/router";
import Button from "@mui/material/Button";
import SendIcon from "@mui/icons-material/Send";

export default function List() {
  const [data, setData] = React.useState([]);
  const router = useRouter();

  React.useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch("/api/list", {
          method: "GET",
          headers: {
            "Content-Type": "application/json",
          },
        });

        const da = await response.json();
        alert(da?.message);
        if (response.status >= 400 && response.status <= 599) {
          await fetch("/api/clearcookie");
          router.push("/");
          return;
        }
        setData(da?.data);
      } catch (err: any) {
        await fetch("/api/clearcookie");
        router.push("/");
        alert(err?.message || "Error");
      }
    };

    fetchData(); // Call the function directly
  }, [router]); // Add dependencies to the dependency array

  const handleLogout = async () => {
    await fetch("/api/clearcookie");
    router.push("/");
    alert("Logged out");
  };

  return (
    <>
      {Array.isArray(data) &&
        !!data?.length &&
        data?.map((item: any) => (
          <Card key={item.id} sx={{ maxWidth: 345 }}>
            <CardActionArea>
              <CardMedia
                component="img"
                height="140"
                image={item.image}
                alt={item.title}
              />
              <CardContent>
                <Typography gutterBottom variant="h5" component="div">
                  {item.title}
                </Typography>
                <Typography variant="body2" color="text.secondary">
                  {item.description}
                </Typography>
              </CardContent>
            </CardActionArea>
          </Card>
        ))}
      <Button onClick={handleLogout} variant="contained" endIcon={<SendIcon />}>
        Logout
      </Button>
    </>
  );
}

Conclusion

By following these steps, you've built a secure React app with JWT tokens and HTTP-only cookies. The use of HTTP-only cookies enhances the security of your application by preventing client-side access to sensitive data. Make sure to adapt and expand this tutorial based on your specific project requirements.

Happy coding! ๐Ÿš€

ย