Secure statically rendered paid content in Next.js (with the App Router)

February 29, 2024 (3 months ago)

When talking about advanced architectural patterns, it's super important to have a real-life use case in mind.

Let's imagine your are running a developer blog, and you've spent an incredible amount of time writing your latest piece, "Remix beats Next.js. Or does it?".

A blog post so good people will pay for reading it

It's so good, so original, so controversial, that it would be shame to share it for free. This brilliant piece of content truly deserves is a paywall.

Let's learn how it can be implemented in Next.js.

We will discover :

  • a wrong way to check authentication and payment (using layouts)
  • a correct but suboptimal way (dynamic check)
  • a better way compatible with static rendering (upfront check in a proxy server)

We won't cover the authentication and payment verification logic per se, but rather how to use this logic efficiently. In this article I suppose that you store the list of paid users in a database, or that you are using a third party service like Stripe.

If you want to read about authentication in Next.js App Router, you can check Lee Robinson video on the topic.

Let's start with the first approach: the one that is super tempting but doesn't actually work!

The bad way: authentication in the layout

In Express, you can protect all routes of an application in a single line of code:

// check auth and payment for all routes
app.use((req, res, next) => {
  if (!hasPaid(req)) {
    return res.redirect("/subscribe");
  }
  next();
});
// only paid users can access this endpoint
app.get("/paid-article", (req, res) => {
  return res.render("paid-article.html");
});

Our goal is to implement the same thing, but in Next.js.

It's very tempting to use a layout for the authentication check. Since a layout is shared by multiple pages, that would be an efficient way to secure these pages wtih a single line of code in the layout, right?

// app/layout.jsx
async function checkPaid() {
  const token = cookies.get("auth_token");
  return await db.hasPayments(token);
}
export default async function Layout() {
  // ❌ this won't work as expected!!
  const hasPaid = await checkPaid();
  if (!hasPaid) redirect("/subscribe");
  // then render the underlying page
  return <div>{children}</div>;
}
// app/page.tsx
// ❌ this can be accessed directly!
export default async function Page() {
  const content = await getContent()
  return <div>{content}</div>
}

Sadly, this doesn't work as expected.

Layouts are not equivalent to top-level middlewares: there is no strict guarantee that a layout is rendered before a page.

Layouts and pages do NOT form a middleware chain like in Express!

When navigating through the application, you won't see the problem, because the layout will be rendered before the page.

However, it's very easy to send a request to force Next server to provide the RSC payload for the page without running the layout first.

I've crafted an open source reproduction of this mistake: eric-burel/securing-rsc-layout-leak

Accessing a private page via Insomnia Here is how we can access the paid content, using Insomnia.

Thanks to the Reddit user who explained this issue to me.

This is not a security issue in Next.js. It was never claimed that layouts were the right place to do authentication in the first place. But since it's pretty easy to confuse layouts for a kind of generic middleware, I wanted to stress out this potential mistake.

The dynamic way: checking auth where you fetch the data

We have started with an invalid approach. Let's now discover a dynamic approach that works as expected.

Instead of authenticating users as early as possible, we do it as late as possible. But not too late hopefully!

The idea is to run the access check when we get the data.

// Check payment
async function checkPaid() {
  const token = cookies.get("auth_token");
  return await db.hasPayments(token);
}
// Get the data
async function getContent() {
  // ✔️ Good, we check auth when we get the data
  const hasPaid = await checkPaid();
  if (!hasPaid) return null;
  return await db.getArticle();
}
// Good practice:
// using a prefix so we can tell that
// 1) this function is deduplicated
// 2) it is suppose to handle its own security
// It makes security auditing easier
const rscGetContent = cache(getContent);
// app/page.tsx
export default async function Page() {
  const content = await rscGetContent();
  return <div>{content}</div>;
}

Great, this works as expected. Doing the auth check in the page would have worked too, but moving the check closer to the data is way easier to read.

The problem is that a dynamic authentication and payment check will imply a dynamic render, everytime a user accesses the page.

You can mitigate this issue by setting up a cache shared among users.

This can be done:

  • using a 3rd party solution like node-cache
  • using Next.js built-in unstable_cache, with the advantage that you will also be able to use the built-in revalidation features

This will reduce the number of calls to your data source, however you will still have to render the page for every request.

The final approach we will discover is compatible with static rendering.

Contrary to React cache, unstable_cache is shared across users. They are not the same thing! If you put user-specific data there, be careful to use the right cache keys and not leak data!

The static way : checking auth upfront in a middleware

The dynamic authentication check worked but was suboptimal for our specific use case.

Given that the paid content is the same for all users, it doesn't make much sense to rerender the page for every request just because we need to read cookies to recognize the user.

Let's make our private page... totally public, but still secure! Yes, it's not incompatible.

Two steps to achieve that:

  1. Remove all the auth checks from your Next.js pages and data fetching methods. Trust me on this.
  2. Check authentication using an Edge Middleware. Tada.

Now you can keep the pages static. Authentication is done upfront, before requests even hit your Next.js app.

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { decrypt } from "./your-auth-system";

export function middleware(req: NextRequest) {
  // In this example
  // I store the payment status directly in the session token
  // You can also call an API like Stripe
  const sessionToken = req.cookies().get("session")?.value;
  const session = decrypt(session);
  if (!session?.isPaid) {
    return NextResponse.redirect("/subscribe", new URL(req.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: "/((?!subscribe|_next/static|_next/image|favicon.ico).*)",
};

The paid content is stored in Next.js cache server-side, and only paid users can reach it. The cool thing is that now, the performances are optimal for them, thanks to static rendering.

If you are not confident with using Next.js middlewares for the authentication check, you can setup any other kind of proxy server. A custom server would also do the job.

The only constraint is that this server should be as fast as possible. It can stay very simple since its only role is to check authentication and payment and run a redirect if needed.

Edge middlewares are built on-top of the Edge runtime, a light JS runtime optimized to be distributed closer to the end users. Middlewares are fast enough, they cannot establish database connections using TCP but they can run HTTP queries, so you can call your payment API from there.

Conclusion

We have learnt how to secure the access to a paid blog, while keeping the content static and thus very fast to load.

But this is not the only use-case. This article is actually a follow-up of my previously published Segmented Rendering pattern.

Segmented Rendering is a way to implement static personalization using a redirection or a URL rewrite. You can reuse this trick to implement many other patterns such as AB testing, feature flagging. It even works for patterns with lesser security constraints, like internationalization.

Compared to the Pages Router, the App Router brings:

  • Layouts, but we shouldn't use them for authentication anyway
  • React Server Components that do not need client-side hydration, so they are more performant when it comes to displaying the static text of an article

Thanks to Segmented Rendering and RSCs, we can achieve the best possible performances for a paid blog!

Want to learn more about security? Join the waitlist for my incoming course, Securing Next.js full-stack applications.

Loved this article?

You will probably also like my Next.js course,

"Securing Next.js full-stack applications"

Authentication, avoiding data leak, auditability and prevention of most common threats... everything's in there to secure your Next.js applications.