DEV Community

Cover image for 9 Tips for Building a Secure Web Application with tRPC, Next.js, Prisma, Turbo, and NextAuth
ktmouk
ktmouk

Posted on

9 Tips for Building a Secure Web Application with tRPC, Next.js, Prisma, Turbo, and NextAuth

I recently made Minute: an open-source time tracking application for individuals using the T3 Stack as a weekend project. In this article, I'll introduce some security tips that I learned while making Minute.


Directory Structure

This section is not directly related to security but is explained first to make the following sections easier to understand.

I adopted Turborepo to manage my source code as a monorepo. Given making a monorepo project, I think the most difficult part is how to separate the source code and what directory structure to use.

At first, I considered creating a directory structure based on Clean Architecture, but after starting, I changed my mind. This is because, while Clean Architecture is a well-known and well-thought-out approach, it is a bit over-engineered for Minute.

I eventually simplified the directory structure like this:

The directory structure of Minute

I will explain some packages, except for those that are obvious.

prisma/

GitHub Link

This package includes the Prisma schema file, migration files, and Prisma Client. Notably, Prisma Client is imported from multiple packages: trpc, service, and apps/web. I recommend creating a prisma/ package as it makes it easier to understand which files are related to Prisma.

schema/

GitHub Link

This contains the Zod schemas for each Prisma model and some constants related to validation, such as MAX_NAME_LENGTH, REGEXP, etc. These are used for validation.

services/

GitHub Link

This contains functions that execute a single procedure, such as inserting or updating a model after validating parameters.

trpc/

GitHub Link

This includes tRPC middleware, routers, and procedures. All procedures contain no logic and simply delegate processing to the appropriate service. By doing this, I can easily switch to another library (e.g., serverAction) in the future without changing the logic, if needed.


Validate Parameters in Each Package

Validation is the most important part of protecting a web application. Therefore, I wanted to validate input parameters passed by users and outputs in both the trpc and service packages. This approach is similar to Contract Programming.

In Contract Programming, every entry point (package) should validate input/output values, and if a validation fails, the function throws an error and does not execute the remaining process for security reasons.

Fortunately, tRPC already has the capability to validate both inputs and outputs, Zod library also has a similar feature named z.function()

I thus made a helper function named contract, which is a tiny wrapper of z.function().

import type { ZodTypeAny } from "zod";
import { z } from "zod";

export const contract = <
  I extends ZodTypeAny,
  O extends ZodTypeAny,
  F extends (input: z.infer<I>) => z.infer<O>,
>(
  { input, output }: { input: I; output: O },
  implement: F,
) => {
  return z.function().args(input).returns(output).implement(implement);
};
Enter fullscreen mode Exit fullscreen mode

And use it in each service package.

export const getUser = (db: PrismaClient) =>
  contract(
    {
      // the userId should be UUID otherwise it throws an error.
      input: z.strictObject({
        userId: z.string().uuid(),
      }),
      // the result object should be the user object.
      output: z.promise(userSchema),
    },
    async (input) => {
      const user = await db.user.findFirst({
        select: {
          id: true,
          name: true,
          image: true,
          createdAt: true,
          updatedAt: true,
        },
        where: {
          id: input.userId,
        },
      });
      if (user === null) {
        throw Error("The user does not exist.");
      }
      return user;
    },
  );
Enter fullscreen mode Exit fullscreen mode

The trpc package also validates input/output data.

export const tasksRouter = router({
  getTasksInFolder: protectedProcedure
    .input(
      z.strictObject({
        folderId: z.string().uuid(),
      }),
    )
    .output(z.array(taskSchema))
    .query(async ({ input, ctx }) => {
      return getTasksInFolder(ctx.db)({
        userId: ctx.currentUserId,
        folderId: input.folderId,
      });
    }),
...
})
Enter fullscreen mode Exit fullscreen mode

I love this approach. Notably, output validation is as important as input validation. For example, imagine we accidentally added sensitive data (e.g., sessionToken) into a result object. Output validation raises an error instead of passing the result to a user.


Setting Custom tRPC Error Formatter

Since tRPC provides a default error formatter, you can use tRPC without having to set up a custom error formatter. However, I recommend setting a custom error formatter.

If an error is thrown, tRPC will return an error.message as a response by default. This is usually not a problem, but some libraries add verbose messages to the error.message when an error occurs.

For example, the Zod library adds a helpful message to error.message.

the Zod library adds a helpful message

This message is useful when debugging, but there is a risk that an attacker can guess what errors have occurred internally. I thus recommend setting a custom formatter like this:

initTRPC.context<typeof createInnerContext>().create({
  transformer: superjson,
  errorFormatter({ shape }) {
    // Hide a message to avoid exposing server errors.
    return { ...shape, message: "" };
  },
});
Enter fullscreen mode Exit fullscreen mode

By doing that, tRPC always returns an empty string if an error occurs.

tRPC returns an empty string


Manage ENVs with T3 Env

T3 Env is used to manage application environment variables type-safely. Since it is rare for an application not to have any environment variables, I recommend this library to everyone who uses Next.js.

If you are going to use this, I also recommend to split schemas into two files: one for public (client) and one for secret (server), according to this warning in the official docs.

This is because validation schemas (keys) for the server variables will be shipped to the client if you don't separate the schema. Of course, ENV values are not exposed, so it's not mandatory. However, validation schemas can provide hints to attackers about what kind of libraries the application uses internally (e.g. NEXTAUTH_***).


Disable fetchCache If You Don't Use It

When creating Minute, an open-source time tracking application, I decided to make API requests on the client side and not make requests on SSR (RSC) since all pages handle private data and require authentication, and I wanted to avoid worrying about caching issues.

If you want to disable caching fetched data, set only-no-store or force-no-store to fetchCache on the page.

export const fetchCache = "only-no-store";
Enter fullscreen mode Exit fullscreen mode

When force-no-store is passed, Next.js does not cache fetched data even if you provide a force-cache option on child components. In contrast, only-no-store also disables caching and throws an error if you provide a force-cache option.

I personally recommend using only-no-store because it alerts you if you or someone else enables caching inadvertently, allowing you to notice the mistake.

Error: cache: 'force-cache' used on fetch for https://... with 'export const fetchCache = 'only-no-store'
Enter fullscreen mode Exit fullscreen mode

Add The CSP Header

The CSP header is essential for mitigating the risk of XSS attacks. Fortunately, here are the instructions on how to set up the CSP header in the Next.js docs.

Apart from the CSP Header, I recommend adding other security headers. There are libraries specifically for adding these headers (e.g next-safe).


Add CSRF prevention

If your Next.js application has API routes, adding Origin header validation in middleware is worthwhile to prevent CSRF attacks. Modern browsers include the origin that caused the request in the Origin header before sending an API request, which can be used to determine whether the request is from the same origin.

const hasValidOrigin = (req: NextRequest) => {
  const origin = req.headers.get("Origin");
  return origin === null || origin === 'https://YOUR-ORIGIN';
};
Enter fullscreen mode Exit fullscreen mode

FYI: This technique is also used in ServerAction.

For those using tRPC, it is also recommended to add validation to ensure that the Content-Type is JSON. This approach will be implemented in tRPC v11, but since v11 is currently in beta, you can add this validation by implementing it yourself.

const hasJsonContentType = (req: NextRequest) => {
  const contentType = req.headers.get("Content-Type");
  return (
    typeof contentType === "string" &&
    contentType.startsWith("application/json")
  );
};
Enter fullscreen mode Exit fullscreen mode

Additionally, if you are using cookies to store session IDs, I recommend checking the HttpOnly, Secure, and SameSite attributes of the cookie. (I believe NextAuth sets these attributes correctly by default, though.)


Add the Cache-Control header

The Cache-Control header is used to control whether the response data can be cached or not. In the case of Minute, all tRPC responses are private and I don't want to cache them.

To add the Cache-Control header, simply add the setting to next.config.js.

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  headers() {
    return [
      {
        source: "/api/:path*",
        headers: [
          {
            key: "Cache-Control",
            value: "private, no-store, no-cache, must-revalidate",
          },
          { key: "Pragma", value: "no-cache" },
        ],
      },
    ];
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that you cannot set Cache-Control headers in next.config.js for pages or assets. Next.js automatically adds appropriate Cache-Control headers to each page (maybe, I hope).


Use server-only

In your Next.js project, there are files meant to be used only on the server, and you may not want to expose them to the client's browser. In that case, you can use the server-only package.

If a file imports server-only packages and is imported from the client, throw an error to prevent leaking server-side code. I recommend adding an import of server-only to every file that obviously does not need to be used by the client. For example, in [...nextauth]/route.ts):

// Prevent importing from client-side code.
import "server-only";

import NextAuth from "next-auth"

const handler = NextAuth({
  ...
})

export { handler as GET, handler as POST }
Enter fullscreen mode Exit fullscreen mode

Test Backend Code

Backend tests are essential for keeping your application healthy. In the case of Minute, since I used Prisma in the backend (trpc and service package), I had to mock the Prisma Client or use a Docker container to set up a database for testing to write the backend tests. The Prisma docs introduce both options: Unit testing and Integration testing. I adopted Integration testing because it is more accurate than Unit testing as it uses a real database.

If you are planning to write integration tests, you may want to reset the database every time the test runs. In that case, the --force-reset option would be helpful.

// package.json
{
  ...
  "scripts": {
    "db:reset": "prisma db push --force-reset"
  }
}
Enter fullscreen mode Exit fullscreen mode

Also, if you are struggling to create dummy data for testing, I recommend FactoryJS, which is for simply creating objects or Prisma records.

When you write tests, I recommend covering both common cases and edge cases. For example, what if the given ID is invalid?

describe("deleteChart", () => {
  describe("when a user has the chart", () => {
    it("deletes the chart and its items", async () => {
      const user = await userFactory.create();
      const chart = await chartFactory.vars({ user: () => user }).create();
      await expect(
        deleteChart(db)({
          id: chart.id,
          userId: user.id,
        }),
      ).resolves.toBeUndefined();
      await expect(
        db.chart.findFirst({
          where: {
            id: chart.id,
          },
        }),
      ).resolves.toBeNull();
    });
  });

  describe("when the id is invalid", () => {
    it("throws an error", async () => {
      const user = await userFactory.create();
      await expect(
        deleteChart(db)({
          id: 'INVALID',
          userId: user.id,
        }),
      ).rejects.toThrow("The chart does not exist.");
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

I introduced some tips that I found during the development of Minute. However, I'm not sure if I've listed all the important tips (probably not, as these libraries are complicated and change day by day). But at least I believe this article will be helpful to everyone who is going to make an application with Next.js, Prisma, etc.

I hope this article is helpful and interesting to you!

If you are interested in Minute, you can access the repository from the link below. We also welcome contributions.

GitHub logo ktmouk / minute

⏰ The open-source time tracking app for individuals.


minute

The open-source time tracking app for individuals.

About

Minute is an open-source time tracking app.
In contrast to other time tracking apps, Minute is mainly focused on individual use and is designed to help users review how they spend their time and use it more meaningfully.

Screenshots

Features

Folders

Manage your tracked time entries with folders and analyze your recent time usage per folder on the report page. You can also view and edit created folders and time entries anytime from the sidebar.

Categories

Group multiple folders into a single category and use them for analysis on the report page. For example, create categories for time you want to reduce and time you want to increase, and use them on the report page.

Custom Charts

Use the created folders and categories to display increases and decreases in time usage on a chart. The items displayed on the chart…

Top comments (0)