JMSWRNR

Protecting Next.js API Routes: Query Parameters

How to protect the query parameters of your Next.js API routes from malicious use.

Type
Article
Published
23 Feb 2023

Introduction

When building a Next.js application, creating an API route that accepts query parameters from your pages is a typical pattern.

For example, my blog uses an API route to serve favicons shown next to external links. Using it to serve the favicon for Google might look something like this:

GET /api/favicon?url=google.com

It's also common to use query parameters to resize images:

GET /api/image/background.jpg?width=500

Or to update information in a data store, for example, when liking a post:

POST /api/posts/like?post_id=12345

These are great from a developer-experience perspective because the parameters can be easily configured. However, from a security perspective, this simplicity allows anyone to call the API route with any parameter values they want.

Why?

Because anyone can use your API route.

This should always be kept in mind during development and ideally covered in tests; what would happen if a user provides parameters with an unexpected value or malicious intent? And why would anyone want to do that?

Let's see how the above examples could be maliciously used:

GET /api/favicon?url=google.com

To resolve a favicon URL, the API route must first fetch the target URL (google.com)

Malicious users could:

  • Use this API route to fetch favicons for their use at your cost.
  • Send a GET request to any URL using your server by changing the query parameter; this malicious request could bypass network security and even be sent to another API.
GET /api/image/background.jpg?width=500

Image transformations can be expensive to process and store.

Malicious users could:

  • Use this API route to transform or serve images at your cost.
  • Send an abnormal number of transformation requests to generate an expensive server bill or deny service to other users.
POST /api/posts/like?post_id=12345

Liking a post could perform an "update or insert" query to a data store, correlating a post_id to the number of likes.

Malicious users could:

  • Send an abnormal number of requests with random post_id values, creating many data queries/entries to generate an expensive server bill or deny service to other users.

Although uncommon in smaller projects, from my experience, this type of abuse is almost always expected when delivering to a large audience.

Protecting parameters

A good level of protection can be added to some of these examples:

  • Verify the image domain before transforming
  • Verify the post_id exists in the blog data store before creating new data entries

This is likely an excellent way to go if you need to accept dynamic input from the user, but this adds a unique complexity to each API route. And how can you protect the favicon route without managing a painful allow list?

If the query parameters always originate from your application build, there is a pattern we can utilize with all of these examples; we'll sign them! ✍️

This means we'll create a digital signature during the build step for each combination of query parameters we use. The API routes can verify this signature to ensure the values originate from our build step, preventing malicious users from tampering with or utilizing them for unintended uses.

I believe JSON Web Tokens (jwt) are an excellent way to achieve this because:

  • The encoded format is suitable to include in URLs
  • Multiple query parameters can be included in the same token
  • jwt libraries support signing and verification by design
  • We don't need to bundle additional JavaScript for the browser

An unprotected example

Here's an example API route without any query protection that returns a message in a JSON response using the name query parameter:

// pages/api/hello.js

export default function HelloAPIHandler(req, res) {
  res.status(200).json({ message: `Hello ${req.query.name}!` });
}

And here is an example page route that uses the SWR library to fetch and render the API response data:

// pages/index.js

export default function HomePage({ name }) {
  const { data } = useSWR(`/api/hello?name=${name}`, fetcher);

  return <div>{data?.message}</div>;
}

export async function getStaticProps() {
  return {
    props: {
      name: 'James'
    }
  }
}

A malicious user could send any value to this API route, even if that value does not originate from your build:

GET /api/hello?name=evil

Signing the parameters

We'll use a secret key to sign and verify the query parameters. We can securely perform the signing during getStaticProps or getServerSideProps. This way, we don't expose the key to the browser, so malicious users cannot create signatures for unauthorized payloads.

One way to ensure this secret key is not exposed is by using environment variables. Next.js will only expose environment variables prefixed with NEXT_PUBLIC_ to the browser.

# .env.local

API_QUERY_KEY=secret123

We can include this in an .env.local to be used locally.

You'll want to configure your deployment or build process with a secret value. For example, I have configured this in my Vercel project dashboard to have a value like NfpkIkEeMULweVxsZBAd.

We can create and sign the token using the JSON Web Token library:

// pages/index.js

export default function HomePage({ token }) {
  const { data } = useSWR(`/api/hello?token=${token}`, fetcher);
  
  return <div>{data?.message}</div>;
}

export async function getStaticProps() {
  return {
    props: {
      token: jwt.sign({ name: 'James' }, process.env.API_QUERY_KEY, {
        subject: 'api-hello',
      }),
    },
  };
}

Notice we also provide a subject of api-hello.

We can use this subject to prevent a signed token from being sent to an unintended API route that uses the same secret key.

For example, if we created a token to be used with /api/hello without a subject, a malicious user could send that same token to /api/goodbye if it uses the same secret key and payload data structure.

Now we can verify and use this token in the API route:

// pages/api/hello.js

export default function HelloAPIHandler(req, res) {
  try {
    const decoded = jwt.verify(req.query.token, process.env.API_QUERY_KEY, {
      subject: 'api-hello',
    });
    return res.status(200).json({ message: `Hello ${decoded.name}!` });
  } catch (e) {
    return res.status(500).end();
  }
}

If the provided token isn't signed with the correct key or doesn't include the valid subject value, the jwt.verify function will throw an error, and if that occurs in this example, we end the connection with a 500 status code.

Conclusion

That is it! This API route will only accept input as a JSON Web Token signed by your secret key with the correct subject.

Malicious users are not able to tamper with or create malicious requests. The only requests that will be accepted are those signed by your internal build process.

A malicious user could repeat a request with the same signed token, but this would likely have little effect if you have a cache layer catching it.

You could also utilize this same pattern in other applications that consume your API route to ensure the origin of the query payload. Just be careful not to expose the secret key.

This is somewhat overkill for many API routes. Still, I believe it's a great option if you want to restrict query parameters and reduce potential service abuse, especially on expensive API routes.