Next.js server action security
Server actions are an elegant way to handle simple functions for common actions like form submissions, but they're a public API so you still need to consider security.
How do you test Next.js app router API route handlers? A guide to testing Next.js API routes and mocking next-auth to properly test authenticated routes.
One of the fun things about software security is learning about unbelievably complex and sophisticated attack methodologies. A favorite of mine was the walkthrough by Google’s Project Zero of the Turing complete CPU that was implemented within a fake GIF inside the iOS PDF rendering component to develop a zero click exploit for iMessage. So elaborate!
Whilst interesting to read about, it’s highly unlikely any normal people will ever be affected by these vulnerabilities. In reality, most attacks are drive-by opportunistic attempts to exploit low-hanging fruit - things like default passwords, known issues in outdated software, or simple logic bugs in code.
The most common form of exploit from the OWASP Top 10 2021 is broken access control i.e. the ability to access data or accounts you shouldn’t have access to. These come about through easy-to-miss errors in permissions checking, missing access control for other HTTP methods like POST or DELETE, or accessing accounts owned by other people due to insecure direct object references or session bugs.
These types of security problems are difficult to detect during development, but often come up during penetration testing. However, it should be relatively easy to write tests to verify the intended behavior and avoid regressions as the code evolves.
Although developers primarily use Arcjet through our security SDKs, we also have a web dashboard for reviewing decisions and request analytics. This is built with Next.js and contains both the frontend and the API the frontend calls (our low-latency gRPC API is implemented in Rust, Go & WebAssembly). We need to make sure we're able to test those endpoints.
This post is about writing tests for Next.js API app router route handlers, previously known as API routes in the pages router. Implementing some simple tests should be able to help protect against both A01:2021-Broken Access Control and A07:2021-Identification and Authentication Failures.
Unfortunately, Next.js does not make it easy to test API route handlers. The official guides only discuss testing frontend components and when you start digging into testing the API routes you find it’s not straightforward.
Next.js has moved towards using standard Request and Response types from TypeScript 5.2+, but there are still extended NextRequest and NextResponse types, Next.js patches the global fetch
function, it uses custom APIs for accessing headers or cookies, has optional segment configurations and offers different runtimes.
Then you need to figure out how to mock your own libraries and start a server to be able to make the requests. Some have reported using node-mocks-http is an option, but I couldn’t get it to work. And what if you want to test for the Edge runtime?
Thankfully, the next-test-api-route-handler package by Xunnamius solves most of these problems. It is test framework agnostic and allows you to emulate Next.js route handlers using the app router, pages router, and the edge runtime.
The package readme provides multiple examples, but assumes you already have a test harness set up. In our case, we followed the Next.js guide for testing with Jest but set the testEnvironment
to node
by installing jest-environment-node
rather than jsdom
.
This resulted in the following base files:
// /jest.config.ts
import type { Config } from "jest";
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: "./",
});
// Add any custom config to be passed to Jest
const config: Config = {
coverageProvider: "v8",
testEnvironment: "node",
moduleNameMapper: {
// Uncomment to provides the Next.js cache function
//react: "next/dist/compiled/react/cjs/react.development.js",
"^@/(.*)$": "<rootDir>/$1",
},
setupFilesAfterEnv: ["./jest.setup.ts"],
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config);
// /jest.setup.ts
import "@testing-library/jest-dom";
// /app/api/hello/route.ts
import { NextResponse } from "next/server";
export async function GET(request: Request) {
return NextResponse.json({ hello: true }, { status: 200 });
}
// /app/api/hello/route.test.ts
import { testApiHandler } from "next-test-api-route-handler"; // Must always be first
import * as appHandler from "./route";
it("GET returns 200", async () => {
await testApiHandler({
appHandler,
test: async ({ fetch }) => {
const response = await fetch({ method: "GET" });
const json = await response.json();
expect(response.status).toBe(200);
await expect(json).toStrictEqual({
hello: true,
});
},
});
});
Then running jest
from the root of the project will execute the test.
Now we have a framework in place, we can extend the tests to mock our authentication library and test to ensure the correct responses are returned. This example uses the open source next-auth library, but next-test-api-route-handler has an example for mocking Clerk middleware which Arcjet also integrates with.
To get the next-auth session you need to call getServerSession. Using the simple example above, an example route handler would look like this:
// /app/api/hello/route.ts
import { authOptions } from "@/lib/auth"; // This is a custom helper as described in https://next-auth.js.org/configuration/nextjs
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({ hello: true }, { status: 200 });
}
We therefore need to mock the call to getServerSession
using Jest:
// /app/api/hello/route.test.ts
import { testApiHandler } from "next-test-api-route-handler"; // Must always be first
import type { Session } from "next-auth";
import * as appHandler from "./route";
let mockedSession: Session | null = null;
// This mocks our custom helper function to avoid passing authOptions around
jest.mock("@/lib/auth", () => ({
authOptions: {
adapter: {},
providers: [],
callbacks: {},
},
}));
// This mocks calls to getServerSession
jest.mock("next-auth/next", () => ({
getServerSession: jest.fn(() => Promise.resolve(mockedSession)),
}));
afterEach(() => {
mockedSession = null;
});
Then in the same test file we can write a test to check that the GET
returns a 403
when there is no session:
// /app/api/hello/route.test.ts
it("GET returns 403 unauthorized when not authenticated", async () => {
mockedSession = null;
await testApiHandler({
appHandler,
test: async ({ fetch }) => {
const response = await fetch({ method: "GET" });
const json = await response.json();
expect(response.status).toBe(401);
await expect(json).toStrictEqual({
error: "Unauthorized",
});
},
});
});
And to simulate a session we set mockedSession
within another test. This gets reset automatically in afterEach
:
it("GET returns 200 when authenticated", async () => {
mockedSession = {
expires: "expires",
user: {
id: "test",
},
};
await testApiHandler({
appHandler,
test: async ({ fetch }) => {
const response = await fetch({ method: "GET" });
const json = await response.json();
expect(response.status).toBe(200);
await expect(json).toStrictEqual({
hello: true,
});
},
});
});
The full test file looks like this:
// /app/api/hello/route.test.ts
import { testApiHandler } from "next-test-api-route-handler"; // Must always be first
import type { Session } from "next-auth";
import * as appHandler from "./route";
let mockedSession: Session | null = null;
// This mocks our custom helper function to avoid passing authOptions around
jest.mock("@/lib/auth", () => ({
authOptions: {
adapter: {},
providers: [],
callbacks: {},
},
}));
// This mocks calls to getServerSession
jest.mock("next-auth/next", () => ({
getServerSession: jest.fn(() => Promise.resolve(mockedSession)),
}));
afterEach(() => {
mockedSession = null;
});
it("GET returns 403 unauthorized when not authenticated", async () => {
mockedSession = null;
await testApiHandler({
appHandler,
test: async ({ fetch }) => {
const response = await fetch({ method: "GET" });
const json = await response.json();
expect(response.status).toBe(401);
await expect(json).toStrictEqual({
error: "Unauthorized",
});
},
});
});
it("GET returns 200 when authenticated", async () => {
mockedSession = {
expires: "expires",
user: {
id: "test",
},
};
await testApiHandler({
appHandler,
test: async ({ fetch }) => {
const response = await fetch({ method: "GET" });
const json = await response.json();
expect(response.status).toBe(200);
await expect(json).toStrictEqual({
hello: true,
});
},
});
});
There’s a lot more you can do here, such as mocking database calls, splitting the mocks into separate files and testing authorization (does this user have access to the right data?), but this sets up the basics so you can ensure that your sensitive Next.js API routes are properly tested for authentication.
Server actions are an elegant way to handle simple functions for common actions like form submissions, but they're a public API so you still need to consider security.
But usually it's good enough to stop 80% of the worst actors with only 20% of the effort of doing it yourself.
How to protect GraphQL backends using Arcjet. Implementing rate limiting and bot protection for Yoga + Next.js.
Get the full posts by email every week.