Published on

General strategies for building secure Next.js applications

Introduction

With major companies facing ransomware attacks, widespread password leaks, and increased cyber criminal activity during COVID lockdowns, it's clear that our digital information is at risk more than ever before.

How can we ensure Next.js applications are safe from malicious intent? How can we protect ourselves and our users' data? Let's explore some easy-to-implement strategies and best practices in this area.

Env Vars

If you're not already familiar with environment variables, they serve to encapsulate all environment-specific aspects of an application. This allows us to use it beyond our local machine. They're also a secure method for storing sensitive information like tokens, passwords, and API keys, ensuring they aren't publicly displayed.

In a Next.js application, incorporating env vars is straightforward. We create a .env file to house our desired secrets. This action loads these variables into the Node.js environment, making them easily accessible for operations such as data fetching:

.env
SECRET_PASSWORD=V3ry$3CReT
API_KEY=8dx9wwo0320r072kma

What if we want to use env vars on the client side? Next.js has built-in support for that, we just have to prefix our environment variable with NEXT_PUBLIC_ and this way we can use our variables in a browser like:

.env
import operationUsingEnvVar from '../utils/usingEnvVar'

operationUsingEnvVar(process.env.NEXT_PUBLIC_STORED_ID)

export default function HomePage() {
  return <h1>Hello World</h1>
}

Env vars provide an excellent means of safeguarding sensitive information. They seamlessly integrate with Next.js applications, especially when deploying them to Vercel. I'd also recommend considering battle-tested libraries like T3 for type-safe environment variable management.

Sanitization

magine building an application that involves user input through forms or similar elements. While seemingly innocent, these components can become potential security vulnerabilities. If not implemented properly, the input section may be exposed to Cross Site Scripting (XSS) attacks, which could have severe consequences.

The defense? Sanitization. In essence, this process involves cleansing user input data of any elements that could pose a threat to our application. By doing so, we eliminate the risk of malicious client-side scripts breaching our defenses, thus enhancing the security of our application. For effective sanitization, I recommend utilizing a trusted library like DOMPurify. However, let's also explore how we can perform basic sanitization ourselves:

import { useState } from 'react'

export default function BasicInputSanitization() {
  const [inputValue, setInputValue] = useState('')

  const handleInputChange = (e) => {
    // This regex removes any non-alphanumeric characters from the input
    const sanitizedValue = e.target.value.replace(/[^a-zA-Z0-9]/g, '')
    setInputValue(sanitizedValue)
  }

  return (
    <input
      type="text"
      value={inputValue}
      onChange={handleInputChange}
      placeholder="Alphanumeric characters only"
    />
  )
}

In the provided example, notice that we don't directly assign the input value to our state. Instead, it passes through our sanitizing function, which removes any non-alphanumeric characters. This serves as a concise overview of the process.

Now, let's take a look at how we can achieve similar results using DOMPurify:

import { useState } from 'react'
import DOMPurify from 'dompurify'

export default function SanitizeHTMLTagsWithDOMPurify() {
  const [inputValue, setInputValue] = useState('')

  function handleInputChange(e) {
    const sanitizedValue = DOMPurify.sanitize(e.target.value, { ALLOWED_TAGS: [] })
    setInputValue(sanitizedValue)
  }

  return (
    <textarea
      value={inputValue}
      onChange={handleInputChange}
      placeholder="Input without HTML tags (using DOMPurify)"
    />
  )
}

HTTP Headers

Next.js offers a straightforward approach to managing HTTP headers, providing us with the means to improve security and exercise control over applications. These headers serve a huge role in fortifying against a lot of security threats while ensuring seamless communication between the client and server.

The most popular among these headers is the Content Security Policy (CSP), which acts as an additional barrier against previously mentioned XSS attacks. It allows you to specify which content sources are permitted to load on your page, therefore reducing the risk of executing malicious scripts. Here's an example of how to implement CSP in Next.js:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        // Apply these headers to all routes in your application.
        source: '/:path*{/}?',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: "default-src 'self'; script-src 'self' example.com",
          },
        ],
      },
    ]
  },
}

In addition to CSP, it's also good to be aware of two other important headers: X-Content-Type-Options and X-Frame-Options.

The X-Content-Type-Options header with the value nosniff helps prevent browsers from incorrectly interpreting files, reducing the risk of potential security vulnerabilities. Meanwhile, X-Frame-Options is used to guard against clickjacking attacks by denying the ability to embed your site within an iframe. Understanding and implementing these headers alongside CSP in Next.js can significantly enhance the security of your web application, creating a safer browsing experience for users.

When dealing with HTTP headers, like req.headers.host, it's crucial not to trust them blindly. Malicious users can modify these headers to try and trick your website. Using these headers without verifying them can expose your site to risks. Always make sure to check and validate header values before using them in your application. It's similar to being cautious with information from someone you're not familiar with. Better safe than sorry!

Dependencies

In many instances, integrating third-party software is the most efficient solution for addressing challenges in our codebase. We install the package, leverage its code, and it often fades into the background. However, there's a potential risk - these packages can introduce vulnerabilities. Dependency chain attacks are a common concern, and even skilled developers can find them confusing. This typically occurs when Library A relies on Library B, which, in turn, uses Library C, and if any library in this chain becomes compromised with malicious code, it can affect all dependent packages. The good news is, that tools like Dependabot and Renovate excel at analyzing high-risk libraries, automatically generating PRs for updates, and easing this responsibility. I highly recommend using it to avoid situations akin to those faced by log4j users.

Conclusion

It's clear that our applications might be vulnerable to various attack scenarios. While we've only scratched the surface here, we may dive deeper into specific areas like database security. It's imperative to remain cautious, recognizing that bad actors will exploit any vulnerability to access users' data. Regularly updating dependencies and staying informed about new vulnerabilities is crucial. Additionally, relying on well-established and trusted solutions for security is often more effective than attempting to build them from scratch.