Protecting Next.js API Routes: Query Parameters
How to protect the query parameters of your Next.js API routes from malicious use.
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.