DEV Community

Oussama Bouyahia
Oussama Bouyahia

Posted on • Updated on

How to Ensure Your Full Stack JavaScript Apps Meet Security Standards

Introduction:

Building a full-stack application is a rewarding challenge, but it's crucial to prioritize security from the very start. Have you considered the essential security principles needed to protect your product? How can we ensure that the application meets security standards effectively, and how easy is it to implement these principles?

In this article, I will explore fundamental security principles for both frontend and backend development. We’ll also discuss practical steps to implement them and ensure that your app adheres to industry security standards.

  1. Securing the frontend:
  • Input Validation and Sanitization:

-Ensure that all input from users is properly validated and sanitized to avoid injection attacks such as Cross-Site Scripting (XSS).

-Use libraries like DOMPurify for sanitizing user inputs that will be inserted into the DOM.

  • Authentication and Authorization:

-Implement secure authentication methods such as OAuth or the famous JWT method (JSON Web Tokens) to ensure that users are who they claim to be.
-Use role-based access control (RBAC) to restrict parts of the UI based on user permissions, for example part of the UI reserved to admin and other part for ordinary users.

// the code below show an example of how user 
//should be authenticated and depending 
//on his role the dashboard UI display
const createRoutes = (logged: boolean, role:string) =>
  createBrowserRouter([
{
          path: "/dashboard",
          loader: combinedDashboardLoader,

          element: (
            <ProtectedRoute authenticated={logged}>
              {role==="admin"}?<AdminDashboard />:<UserDashboard/>
            </ProtectedRoute>
          ),
          errorElement: <ErrorPage />,
        }])

Enter fullscreen mode Exit fullscreen mode
  • Secure Storage of sensitive Data:

-Avoid storing sensitive information like access tokens in localStorage or sessionStorage as they are vulnerable to XSS. Instead, use secure cookies with the HttpOnly and Secure flags enabled.

// in your server code you can write this code
//  inside your login controller function
// so you don't need to send your token to the frontend
res.cookie("accessToken", accessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      maxAge: expirationTime * 1000,
      sameSite: "lax",
    });

Enter fullscreen mode Exit fullscreen mode
  • CORS (Cross-Origin Resource Sharing):

-Implement proper CORS policies to only allow specific origins to interact with your backend API. Ensure that you restrict methods and headers that are allowed.

  • Content Security Policy (CSP):

-Set up a robust CSP to prevent the execution of untrusted scripts on your frontend. This reduces the risk of XSS attacks by controlling where resources can be loaded from.
-_Implementation _:
the easiest way is to simply use meta tags in your head of the html page as shown below in the code , for more details you can refer to this link https://www.stackhawk.com/blog/react-content-security-policy-guide-what-it-is-and-how-to-enable-it/

  <head>
    ...
    <meta http-equiv="Content-Security-Policy" 
          content="...policy definition here..." />
  </head>
Enter fullscreen mode Exit fullscreen mode

-Alternatively you can implement a middleware in your EpressJS as below:

app.use((req, res, next) => {
  res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' https://trusted.cdn.com; object-src 'none';");
  next();
});

Enter fullscreen mode Exit fullscreen mode
  • HTTPS:

Enforce HTTPS to encrypt communication between the client and the server, ensuring that data is not exposed during transit.

2 . Securing the backend:

  • Input Validation and Sanitization (Server-side):

-Always validate and sanitize user inputs on the server side to prevent SQL injection or other code injection attacks. Even if input validation is done on the frontend, it should always be performed on the backend as well.
below an example using express validator middleware, then simply apply this middleware before your registration controller function.

import { body, validationResult } from "express-validator";
const validateUserInput = [
    body("email").isEmail().withMessage("Please enter a valid email address"),
    body("name")
        .isLength({ min: 3 })
        .withMessage("Name must be at least 3 characters long"),
    body("password")
        .isLength({ min: 6 })
        .withMessage("Password must be longer than 5 characters"),

    // Middleware to check for validation errors
    (req, res, next) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
            return res.status(400).json({ errors: errors.array()[0].msg });
        }
        next();
    },
];
export default validateUserInput;
//in your router file apply it 
//register is the post request function to add new user
router.route("/register").post(validateUserInput, register);

Enter fullscreen mode Exit fullscreen mode
  • Authentication and Authorization:

-Use robust authentication mechanisms, such as OAuth, and secure tokens like JWT for session management.

-Implement role-based access control (RBAC) and avoid hardcoding permissions directly in your application.

  • Password security:

-Store passwords securely using strong hashing algorithms like bcrypt or Argon2. Never store plain text passwords.

-If needed implement multi-factor authentication (MFA) for an additional layer of security.

the code below shows how the process of securing password and using JWT (authentication).

// hashing password inside your registration function  
const register = async (req: Request, res: Response) => {
  const { email, password, name } = req.body;
const hashedPassword = await bcrypt.hash(password, 10);
createUser(email,name,hashedpassword) //a function to add new user into your DB
}

// Compare the password to the hashed one during the login request
const login = async (req: Request, res: Response) => {
  const { email, password } = req.body;
const existingUser = await query(findUserByEmail, [email]);
    if (!existingUser[0])
      return res.status(404).json({
        message: `${email} not found! register if you don't have an account`,
      });
    const matchPassword = await bcrypt.compare(
      password,
      existingUser[0].password
    );
//generate token JWT
    const expirationTime = 180;
    const accessToken = jwt.sign(
      { id: existingUser[0].iduser },
      process.env.ACCESS_TOKEN_SECRET!,
      {
        expiresIn: expirationTime + "s",
      }
    );}

Enter fullscreen mode Exit fullscreen mode
  • Rate Limiting and Throttling: To prevent brute-force attacks, implement rate limiting and IP-based throttling on sensitive endpoints such as login routes.

Rate Limiting:
It restricts the number of requests a user (identified by IP, API key, etc.) can make to the server in a given time window (e.g., 100 requests per minute). After the limit is reached, further requests are blocked or delayed.

const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();

// Define rate limiting rule: 100 requests per 15 minutes
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests 
  message: 'Too many requests from this IP, please try again later.', // Custom message 
  standardHeaders: true,  `RateLimit-*` headers
  legacyHeaders: false, 
});

// Apply the rate limiting middleware to all requests

app.use(limiter);

//but we can also apply it to a specific route 
app.post('/login', limiter, (req, res) => {
  res.send('Login route');
});


app.listen(3000, () => {
  console.log('Server is running on port 3000');
});


Enter fullscreen mode Exit fullscreen mode

Throttling:
Similar to rate limiting, but instead of blocking requests entirely, it slows down the processing of requests after a certain threshold is reached. This can avoid service overloads.

Example:
If a user makes more than 10 API requests per second, you slow down the response to every subsequent request to ensure the system isn’t overwhelmed.

  • Data encryption:

Use encryption for sensitive data both at rest and in transit. Ensure that databases and other storage mechanisms encrypt sensitive information.

the code below shows an implementation of data encryption of a credit card number before storing it in the database.

const crypto = require('crypto');

// Encryption settings
const encryptionKey = crypto.randomBytes(32); // AES-256 key
const iv = crypto.randomBytes(16); // Initialization vector

// Encrypt function
function encrypt(text) {
  const cipher = crypto.createCipheriv('aes-256-cbc', encryptionKey, iv);
  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  return `${iv.toString('hex')}:${encrypted}`; // Store both IV and encrypted text
}

// Post request
app.post('/user', (req, res) => {
  const { creditCard, email } = req.body;

  // Encrypt the name
  const encryptedCard = encrypt(creditCard);

  // Save encrypted  to the database

  const user = new User({
    creditCard: encryptedCard ,
    email: email
  });// example of sequelize queries

  user.save().then(() => {
    res.send('User saved successfully');
  }).catch(err => {
    res.status(500).send('Error saving user');
  });
});


Enter fullscreen mode Exit fullscreen mode

When you need to display the real data (in this case the card number) , you can use decrypt function inside your GET request.

// Decrypt function
function decrypt(encryptedData) {
  const [ivHex, encryptedText] = encryptedData.split(':');
  const ivBuffer = Buffer.from(ivHex, 'hex');
  const decipher = crypto.createDecipheriv('aes-256-cbc', encryptionKey, ivBuffer);
  let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

//GET request
app.get('/user/:id', (req, res) => {
  const userId = req.params.id;
 User.findById(userId).then(user => {
    if (!user) return res.status(404).send('User not found');

    // Decrypt the user's card 
    const decryptedCard = decrypt(user.creditCard);

 res.json({
      creditCard: decryptedCard ,
      email: user.email
    });
  }).catch(err => {
    res.status(500).send('Error retrieving user');
  });
});
Enter fullscreen mode Exit fullscreen mode
  • Security Headers:

-Apply HTTP security headers like Strict-Transport-Security, X-Frame-Options, X-XSS-Protection, and X-Content-Type-Options to protect against various common vulnerabilities.

The simplest way to implement it is by using the helmet middleware in express after installing it npm install helmet.

const express = require('express');
const helmet = require('helmet');
const app = express();

app.use(helmet());

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

Enter fullscreen mode Exit fullscreen mode

The alternative implementation is to manually setting Headers without Helmet as shown as below.

app.use((req, res, next) => {
  res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' https://trusted.cdn.com");
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  res.setHeader('X-XSS-Protection', '1; mode=block');
  res.setHeader('Referrer-Policy', 'no-referrer');
  res.setHeader('Permissions-Policy', 'geolocation=(self), microphone=()');
  next();
});
Enter fullscreen mode Exit fullscreen mode
  • CSRF Protection:

-Implement Cross-Site Request Forgery (CSRF) protection mechanisms such as CSRF tokens to prevent unauthorized actions in your app.

How CSRF Attacks Work:

-The user logs into your app, and the server sets a session cookie or an authentication token.
-The user then visits a malicious site while still logged in to your app in the background.
-The malicious site sends a request to your app using the logged-in user's credentials (session cookie) to perform unintended actions like transferring money or changing account details.

To prevent CSRF attacks, you need to ensure that requests made from your frontend are authenticated and originate from a trusted source. The most common way to protect against CSRF is by using CSRF tokens.

Implementation is easy by using cookie parser and express.urlencoded middlewares
first install them npm install csurf cookie-parser

const express = require('express');
const cookieParser = require('cookie-parser');
const csrf = require('csurf');
const app = express();

// Use cookie-parser for CSRF token storage (if using cookie-based tokens)
app.use(cookieParser());

// Initialize the CSRF middleware
const csrfProtection = csrf({ cookie: true });

// Use body-parser to parse form data
app.use(express.urlencoded({ extended: false }));

// routes here after applying the middlewares

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});
Enter fullscreen mode Exit fullscreen mode
  • Logging and monitoring:

-Ensure proper logging of security-related events such as failed login attempts and potential XSS attacks. Also, set up monitoring and alerts for abnormal behavior.

Conclusion:

This article aimed to highlight and summarize the key security principles that should be considered when developing full-stack JavaScript applications. We also explored practical ways to implement these principles efficiently. Additionally, these insights can be valuable when preparing for job interviews, especially when discussing various security measures and best practices in app development.

Top comments (0)