Skip to content

How to Add a Paywall to Your App: Server-Side Feature Gating

A paywall is a server-side check on every paid request, read from a subscription status your webhook keeps current. Gate your Next.js features with Flint.
Subscriptions13 min readBy Flint Pay, Product & API Team

Ask Claude Code, Codex, or Cursor to "add a paywall" and the first pass is mostly UI: a Pro badge, a locked button, a pricing page. The Export button disappears for free users, and the API route behind it still answers anyone who calls it. A paywall that lives in the UI shows paying users more buttons. It doesn't stop anyone else.

The short answer#

A paywall is a check on your server, in every request that returns paid data or runs a paid action. It reads the user's subscription status and refuses when that status doesn't grant access. The status has to stay current as trials end, renewals fail, and users cancel.

On Flint, the subscription lives in Flint: trials, renewals, retries on declined cards, cancellations, and the hosted account where users update a card or cancel. Your app keeps one row per user with the subscription's status and plan, written by a webhook, and one helper that every paid code path calls:

  1. A free user clicks Upgrade. Your server creates a Flint checkout session for the plan, and the user pays on Flint's page.
  2. Flint sends a subscription webhook. Your handler fetches the subscription and writes its status and plan to the user's row.
  3. Every paid page, API route, and server action calls requirePlan(), which reads that row.

Create a sandbox and your agent can build the gate and test it against test cards before you verify a business. Test keys are free.

Where AI-built paywalls leak#

Each of these passes the test an agent runs on its own work: sign up, pay with a test card, watch the feature unlock. They fail when someone skips the UI.

The button is hidden and the route is open#

The agent wraps the button in {isPro && <ExportButton />} and leaves app/api/export/route.ts with no check. Anyone signed in can call the route from the browser's network tab or with curl. Server Actions work the same way. Next.js documents that an action runs as a POST request and "the route is reachable to anyone who can send the same POST," so rendering a form only for paying users protects nothing.

The paid flag lives in the browser#

The agent stores localStorage.setItem("plan", "pro") after checkout, or the client sends { "plan": "pro" } and the server trusts it. The user can edit both in DevTools. Your server derives the plan from the signed-in user's ID and its own records, never from a value the client sends.

The check runs only in middleware#

In March 2025, Next.js disclosed CVE-2025-29927, rated critical. A request carrying the internal x-middleware-subrequest header could skip middleware entirely, and in the advisory's words, "it is possible to bypass authorization checks within a Next.js application, if the authorization check occurs in middleware." Self-hosted apps running next start or output: 'standalone' were affected; Vercel and Netlify deployments weren't. Versions 12.3.5, 13.5.9, 14.2.25, and 15.2.3 fixed it.

Upgrading closes that hole. The lesson outlasts it: check in the code that returns the data too. Since Next.js 16, middleware is called Proxy, and the Next.js docs say Proxy "should not be your only line of defense" and that a matcher change can silently remove its coverage from Server Functions. A check in a shared layout has a similar gap, because layouts don't re-render on navigation.

Access turns on from the success page#

The success page is where the buyer lands, not proof that they paid. A buyer can close the tab before it loads, and anyone can open the URL directly. Flint adds checkout_session_id and order_id to it, which identify the checkout without proving payment. Turn access on from the webhook; how to take payments in a vibe-coded app has the handler.

The agent signs plan: "pro" into the session token at login, or sets is_pro = true when checkout completes and never writes it again. A session token stays valid until it expires, whatever happens to the subscription, and a flag written once never turns off for a failed renewal or a cancellation. Read access on each request from a value the webhook keeps current.

Which statuses mean access#

A Flint subscription is always in one of seven statuses. Two more situations need a rule of their own: a cancellation scheduled for the end of the period, and a downgrade that takes effect at the next renewal.

Subscription stateAccessWhy
trialingOnThe card is saved and validated, and Flint charges it at trial_end_at
activeOnPaid through current_period_end_at
past_dueYour product decisionThe renewal failed. Flint is retrying the card and has emailed the user a link to update it. Keep access and show a banner, or restrict paid actions
incompleteOffThe first payment is still being collected. Wait for subscription.activated
scheduledOffNothing is charged until starts_at
pausedOffBilling is suspended. A resumed subscription can be past_due while Flint collects payment, so access follows the status, not the resume event
canceledOffFinal. A returning user starts a new subscription
Cancellation scheduled for period endOn until subscription.canceledThe status stays active or trialing with cancel_at_period_end: true, and scheduled_cancel_at says when it ends
Downgrade with billing_starts: "next_renewal"Keep the current plan's access until current_period_end_atsubscription_plan_id changes to the new plan immediately, and Flint doesn't keep the old plan visible, so your app holds the old access

A grace period on past_due usually ends on its own: Flint retries by default four times over 16 days, and when retries run out, subscriptions on Flint's billing schedule are canceled by default. If you set the end action to notify_only, the subscription stays past_due after the last retry, so give your grace period an end date of its own.

Keep the status current#

Your server can know a user's status in two ways: keep a copy that the webhook writes, or ask Flint on every request.

Cached copy written by the webhookRead Flint on every request
LatencyOne row read, often alongside the session read you already doA call to Flint before every paid response
LimitsNone from Flint; reads come from your databaseEach call counts against Flint's read rate limit: 60 per second per API key and 200 per second per merchant
FreshnessCurrent once the webhook arrives; Flint retries a failed delivery up to 9 times over about three daysCurrent on every read
When Flint is unreachableThe gate keeps workingYou choose between failing open and failing closed

Use the cached copy. Three habits keep it correct:

  • Set, don't toggle. Write the subscription's current status and plan on every event. A duplicate delivery writes the same values again and changes nothing.
  • Fetch on each event. Deliveries can repeat and arrive out of order. The event says which subscription changed; GET /v1/subscriptions/{subscription_id} says what it looks like now.
  • Keep the newest read. A subscription's version goes up on every change, so a fetch that finishes late can't overwrite a newer one.

Call this from your verified webhook route for every subscription.* event:

TypeScript
// lib/sync-access.ts
import "server-only";
import { findUserIdByFlintCustomer, readAccess, writeAccess } from "@/lib/db";

export async function syncAccess(subscriptionId: string) {
  const response = await fetch(
    `https://api.withflintpay.com/v1/subscriptions/${subscriptionId}`,
    {
      headers: { Authorization: `Bearer ${process.env.FLINT_API_KEY}` },
      cache: "no-store",
    },
  );
  if (!response.ok) throw new Error(`Flint returned ${response.status}`);
  const { data: subscription } = await response.json();

  const userId = await findUserIdByFlintCustomer(subscription.customer_id);
  if (!userId) return;

  const stored = await readAccess(userId);
  if (stored?.subscriptionId === subscription.subscription_id) {
    // An older read finished after a newer one.
    if (stored.version > subscription.version) return;
  } else if (stored && subscription.status === "canceled") {
    // A late event for an old subscription must not replace the current one.
    return;
  }

  const isNewSubscription = stored?.subscriptionId !== subscription.subscription_id;
  await writeAccess(userId, {
    subscriptionId: subscription.subscription_id,
    status: subscription.status,
    planId: subscription.subscription_plan_id,
    version: subscription.version,
    // A downgrade hold belongs to the subscription it was set on.
    ...(isNewSubscription ? { heldPlanId: null, heldUntil: null } : {}),
  });
}

Throwing on a failed fetch lets your route return an error, so Flint delivers the event again later. Enable the subscription events on your endpoint, including subscription.updated, which reports a trial ending, and subscription.plan_changed, which your gate needs once access depends on the plan. Signature verification on the raw body and the rest of the route are in how to take payments in a vibe-coded app, and Webhooks covers the 10-second response budget.

The gate in Next.js#

The examples use the App Router. Each user's access row holds:

FieldComes from
subscriptionIdsubscription_id
statusstatus
planIdsubscription_plan_id
versionversion
heldPlanId, heldUntilYour downgrade action, when it keeps the old plan until the period ends

One helper decides access. It runs on the server only and returns a result the caller has to check, so each call site can refuse in its own way: a page redirects, an API route returns 403, an action returns an error.

TypeScript
// lib/paywall.ts
import "server-only";
import { cache } from "react";
import { getCurrentUser } from "@/lib/auth"; // your auth library
import { readAccess, type AccessRow } from "@/lib/db";

export type Tier = "pro" | "team";

const TIER_BY_PLAN: Record<string, Tier> = {
  [process.env.FLINT_PRO_PLAN_ID!]: "pro",
  [process.env.FLINT_TEAM_PLAN_ID!]: "team",
};
const RANK: Record<Tier, number> = { pro: 1, team: 2 };

// trialing and active grant access. past_due is a product decision:
// this app keeps access and shows a banner while Flint retries.
const PAID = new Set(["trialing", "active", "past_due"]);

export type Gate =
  | { ok: true; userId: string; tier: Tier; access: AccessRow }
  | { ok: false; reason: "signed_out" | "upgrade_required" };

export const requirePlan = cache(async (needed: Tier): Promise<Gate> => {
  const user = await getCurrentUser();
  if (!user) return { ok: false, reason: "signed_out" };

  const access = await readAccess(user.id);
  if (!access || !PAID.has(access.status)) {
    return { ok: false, reason: "upgrade_required" };
  }

  const holding = access.heldPlanId && access.heldUntil && new Date() < access.heldUntil;
  const tier = TIER_BY_PLAN[holding ? access.heldPlanId! : access.planId];
  if (!tier || RANK[tier] < RANK[needed]) {
    return { ok: false, reason: "upgrade_required" };
  }

  return { ok: true, userId: user.id, tier, access };
});

React's cache keeps a page from reading the row twice in one render. Server Actions and Route Handlers aren't part of a render, so there it simply runs on every call, which is what a gate should do.

A server component checks before it loads anything:

TSX
// app/reports/page.tsx
import { redirect } from "next/navigation";
import { requirePlan } from "@/lib/paywall";

export default async function ReportsPage() {
  const gate = await requirePlan("pro");
  if (!gate.ok) redirect(gate.reason === "signed_out" ? "/login" : "/pricing");

  const reports = await getReports(gate.userId);
  return (
    <>
      {gate.access.status === "past_due" && <UpdateCardBanner />}
      <ReportList reports={reports} />
    </>
  );
}

A route handler returns a status code instead of a redirect, because its caller is code:

TypeScript
// app/api/reports/export/route.ts
import { requirePlan } from "@/lib/paywall";

export async function POST() {
  const gate = await requirePlan("pro");
  if (!gate.ok) {
    const status = gate.reason === "signed_out" ? 401 : 403;
    return Response.json({ error: gate.reason }, { status });
  }

  const csv = await buildExport(gate.userId);
  return new Response(csv, { headers: { "Content-Type": "text/csv" } });
}

A server action checks again, even though only Team users see its form:

TypeScript
// app/reports/actions.ts
"use server";

import { requirePlan } from "@/lib/paywall";

export async function scheduleReport(formData: FormData) {
  const gate = await requirePlan("team");
  if (!gate.ok) return { error: "Scheduled reports are on the Team plan." };

  await saveSchedule(gate.userId, String(formData.get("schedule")));
  return { ok: true };
}

The UI can still hide the Export button for free users. That's presentation; the three checks are the paywall.

If your app runs on Supabase, the same rule can live in the database too: a row-level security policy that reads the access table, so a query from the browser returns paid rows only to paying users. Add payments to a Supabase app has the policy and the webhook as an Edge Function. For environment variables and one webhook endpoint per environment on your host, see add payments on Railway, Vercel, Render, and Netlify.

Upgrade, change plans, and manage billing#

A paywall needs three ways out: a free user upgrades, a subscriber changes plans, and a subscriber updates a card or cancels. Here they are as server actions:

TypeScript
// app/billing/actions.ts
"use server";

import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import { holdPlan } from "@/lib/db";
import { requirePlan } from "@/lib/paywall";

async function flint(path: string, body: object, idempotencyKey: string) {
  const response = await fetch(`https://api.withflintpay.com/v1${path}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FLINT_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error(`Flint ${path} returned ${response.status}`);
  return (await response.json()).data;
}

// Upgrade button for a user with no subscription.
export async function startCheckout() {
  const user = await getCurrentUser();
  if (!user) redirect("/login");

  const session = await flint(
    "/checkout-sessions",
    {
      type: "subscription_plan",
      subscription_plan_id: process.env.FLINT_PRO_PLAN_ID,
      customer_id: user.flintCustomerId,
      redirects: { success_url: `${process.env.APP_URL}/welcome` },
    },
    crypto.randomUUID(),
  );
  redirect(session.url);
}

// Team to Pro, starting at the next renewal.
export async function downgradeToPro() {
  const gate = await requirePlan("team");
  if (!gate.ok) redirect("/pricing");
  const { access } = gate;
  if (access.planId === process.env.FLINT_PRO_PLAN_ID) redirect("/settings/billing");

  const { subscription } = await flint(
    `/subscriptions/${access.subscriptionId}/change-plan`,
    { subscription_plan_id: process.env.FLINT_PRO_PLAN_ID, billing_starts: "next_renewal" },
    `downgrade-${access.subscriptionId}-v${access.version}`,
  );
  // Flint switches the plan now. Keep Team until the paid period ends.
  await holdPlan(gate.userId, access.planId, new Date(subscription.current_period_end_at));
  redirect("/settings/billing");
}

// Manage billing link on your settings page.
export async function openBillingAccount() {
  const user = await getCurrentUser();
  if (!user) redirect("/login");

  const link = await flint(`/customers/${user.flintCustomerId}/access-links`, {}, crypto.randomUUID());
  redirect(link.url);
}

Each one is a form with a button, such as <form action={startCheckout}><button>Upgrade to Pro</button></form>. redirect throws to end the action, so keep it outside any try block.

Upgrade. startCheckout creates a checkout session for the plan with the user's Flint customer_id, which your app stores when the user signs up, and sends the browser to the url Flint returns. The user pays on Flint's page, and access turns on when the webhook arrives. A user who already has a subscription changes plans instead of checking out a second time.

Change plans. POST /v1/subscriptions/{id}/change-plan takes the new subscription_plan_id and a required billing_starts. Only your API key can call it, so plan changes always go through your server. An upgrade usually starts now: Flint charges the new plan today minus a credit for the unused part of the current period, and switches the plan only if that payment succeeds. A downgrade usually waits for next_renewal, which charges nothing now. The idempotency key includes the subscription's version, so a double click doesn't send a second change. Before either one, show the user the next charge with a plan change preview (POST /v1/subscription-previews with mode: "plan_change"). Flint doesn't email the user when a plan change raises their price, so tell them yourself. The subscription billing guide has the credit math.

Manage billing. openBillingAccount creates a one-time sign-in link to Flint's hosted account and redirects to it. The link works for one open within 15 minutes and signs the user in without a code, so send it straight to the browser and keep it out of logs. In the hosted account, users update their card, pause, cancel, or undo a scheduled cancellation, within the rules you set: pause length, whether a cancellation ends at the period's close, and whether to offer a pause first. You don't build any of those screens. Each change reaches your app as a webhook, and syncAccess writes the new status. Customer accounts covers branding and running it on your own domain.

Test the gate#

Run these in the sandbox before you go live:

TestHowWhat should happen
A user who never paid calls a paid routeSign in as a free user, copy the session cookie, and curl -X POST your export route with it, then without it403, then 401, and no data either time
A trialSubscribe to a plan with a one-day trial, paying with 4242 4242 4242 4242Access is on while the subscription is trialing and stays on when the trial converts to active
A failed renewalOn a daily plan, save the 4000 0000 0000 0341 card and point the subscription at it with POST /v1/subscriptions/{id}/payment-methodThe next daily renewal fails, subscription.past_due arrives, your grace banner shows, and Flint emails the user. Point it back at a 4242 card and call POST /v1/subscriptions/{id}/payment-retries to see access recover
A cancellation scheduled at period endCancel from the hosted account, or call POST /v1/subscriptions/{id}/cancel without cancel_immediately, on a daily planAccess stays on while cancel_at_period_end is true, then turns off when subscription.canceled arrives
A downgradeRun the downgrade action from Team to Prosubscription_plan_id changes at once, Team features stay on until current_period_end_at, then only Pro features work

The daily plan matters: renewals and period ends arrive within a day, so you can watch the whole path without waiting a month. Testing lists every test card.

The prompt#

Paste this into Claude Code or Codex after connecting the Flint docs server (claude mcp add --transport http flint-docs https://developers.withflintpay.com/mcp):

Text
Add a server-side paywall to this app with Flint. Use the Flint
docs MCP server for every endpoint, and keep FLINT_API_KEY on the
server only.

1. Add an access table: one row per user with subscription_id,
   status, subscription_plan_id, version, held_plan_id, and
   held_until.
2. In the Flint webhook route, verify the signature on the raw body
   with the standardwebhooks npm package, then fetch the
   subscription for every subscription.* event and write its
   current status, plan, and version. Skip a write whose version is
   older than the stored one.
3. Write one requirePlan(tier) helper that reads the row. trialing
   and active grant access, past_due keeps access with an
   update-card banner, everything else denies. Honor held_plan_id
   until held_until.
4. Call requirePlan in every page, route handler, and server action
   that returns paid data or runs a paid action. Never trust a plan
   sent by the client, and don't rely on proxy or middleware alone.
5. Add Upgrade (checkout session), Downgrade (change-plan with
   next_renewal, then hold the old plan until current_period_end_at),
   and Manage billing (customer access link) as server actions.
6. Run the tests: a free user calling a paid route directly, a
   trial, a failed renewal on a daily plan with the 0341 card, a
   cancellation at period end, and a downgrade. Show me the results.

What Flint keeps out of your app#

Your paywall comes down to one table, one webhook function, and one helper. Renewals, retries on declined cards, dunning emails, trial conversion, unused-time credits on plan changes, and the screens where users update a card or cancel all run in Flint, so the code your agent writes handles a status and a plan ID, not money. As the app sells more, the next features are calls on the same subscription: an annual plan is another plan with billing_interval: "yearly", and moving a subscriber onto it is a plan change. The Stripe go-live checklist for vibe-coded apps goes through the rest of the billing checks.

Sign up for Flint, create a plan in the sandbox, and paste the prompt.

FAQ#

Can users bypass a paywall?#

They can bypass any check that runs in the browser: hidden buttons, flags in localStorage, and fields the client sends. They can't bypass a check your server runs before it returns data, as long as every route, server action, and page that serves paid data runs it. In Next.js, a check in middleware or Proxy alone has been bypassed before (CVE-2025-29927), so check in the code that returns the data too.

Should I check subscription status on every request?#

Check access on every request that returns paid data or runs a paid action, but read it from your own database, not from the payment provider. A webhook keeps that copy current. Calling Flint on every request adds a network round trip and counts against the API's read rate limit of 60 requests per second per key.

What happens to access when a payment fails?#

The subscription moves to past_due, Flint emails the user a link to update their card, and Flint retries the charge, by default four times over 16 days. Whether a past_due user keeps paid features is your decision. A common choice is to keep access and show a banner. A successful retry makes the subscription active again, and by default it's canceled when the retries run out.

How do I give access during a free trial?#

Set trial_period_days on the plan. A subscriber starts as trialing with their card saved and validated but not charged, so grant access for trialing the same as for active. Flint charges the first period at trial_end_at. A user who cancels during the trial keeps access until the trial ends and is never charged.

Does a paywall need a database?#

It needs somewhere your server can read each user's access quickly, and in most apps that's a table in the database you already have. Without one, your server has to ask Flint on every paid request, which adds latency and runs into rate limits as traffic grows. A paywall needs no card data in your database: Flint keeps the payment details, and your row holds a status and a plan ID.

Sources#

Third-party facts above were checked on 2026-10-26.

  • GitHub advisory GHSA-f82v-jwr5-mffw: CVE-2025-29927, "Authorization Bypass in Next.js Middleware," rated critical, published March 21, 2025; affects authorization checks in middleware; patched in 12.3.5, 13.5.9, 14.2.25, and 15.2.3.
  • Postmortem on Next.js middleware bypass: the x-middleware-subrequest header; self-hosted apps using next start and output: 'standalone' were affected, Vercel and Netlify deployments weren't; middleware isn't recommended as the sole method of protecting routes.
  • Next.js authentication guide: Proxy "should not be your only line of defense"; layouts don't re-render on navigation; React cache doesn't deduplicate in Server Actions and Route Handlers; authorize inside Server Actions and Route Handlers.
  • Next.js proxy.js reference: middleware was renamed to Proxy in v16.0.0; a matcher change can silently remove Proxy coverage from Server Functions.
  • Next.js Server Actions guide: an action runs as a POST request reachable to anyone who can send it; authenticate and authorize inside every action.
  • Next.js redirect reference: redirect works in Server Components, Route Handlers, and Server Functions, accepts absolute URLs, and throws, so it belongs outside try blocks.
  • Supabase row level security: policies are SQL that can check other tables and apply to requests made with a publishable key.