Portreeve
how-to · 6 Sept 202612 min read

How to stop free trial abuse in a Next.js app

Stop free trial abuse in a Next.js app: screen the signup in an App Router route handler, collect a device token, and revoke denied accounts by webhook.

To stop free trial abuse in a Next.js app you need three pieces wired into the App Router: a device token collected in the browser, one screening call inside the route handler that creates the account, and a webhook that can revoke an account after the fact. By the end you will have all three running against test keys and a curl command that puts a real event in your dashboard.

The account creation request is where this has to live. A trial abuser's entire method is to arrive looking like a first-time visitor, and the one moment you hold every signal at once (email, IP, device, and the user id you are about to write) is the POST that creates the row.

Stripe reported that from November 2025 to February 2026 its models "detected 6.2x more abusive free trials across Stripe's network", with AI products hit hardest because free GPU time converts into money faster than most things a person can take from you.

What you need before you start

  • Next.js 15 or 16 on the App Router, Node 18.17 or newer, and a signup route that creates a user.
  • Two columns on your user table: the screening event id, and a review_pending flag. One more, disabled_at, if you do not already have a way to turn an account off.
  • A Portreeve test secret key (sk_test_...) and publishable key (pk_test_...). The free tier is 1,000 screened events a month with no card.
  • For step 5, a way to receive an HTTPS webhook: a tunnel in front of next dev, or a deployed preview.

Budget about twenty minutes. Steps 1 through 4 stand on their own if you want to stop before the webhook.

1. Install the SDKs and collect a device token

npm install portreeve @portreeve/browser

Two keys, and only one of them is safe in the bundle:

# .env.local
PORTREEVE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_PORTREEVE_PUBLISHABLE_KEY=pk_test_...
PORTREEVE_WEBHOOK_SECRET=...

The publishable key needs the NEXT_PUBLIC_ prefix because it is read in a client component. The secret key must never get that prefix; anything with it is inlined into JavaScript you ship to the browser.

Now the form. collectDeviceToken runs on submit, not on mount:

// app/signup/signup-form.tsx
"use client";

import { useState } from "react";
import { collectDeviceToken } from "@portreeve/browser";

export function SignupForm() {
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = new FormData(e.currentTarget);

    const device_token = await collectDeviceToken(
      process.env.NEXT_PUBLIC_PORTREEVE_PUBLISHABLE_KEY!
    );

    const res = await fetch("/api/signup", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        email: form.get("email"),
        password: form.get("password"),
        device_token,
      }),
    });

    if (!res.ok) return setError("We could not create that account.");
    window.location.href = "/welcome";
  }

  return (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">Start free trial</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

Tokens live 15 minutes. Collect on mount and a form left open through a phone call arrives with an expired token. That token is silently dropped, and you screen blind on the exact signal you installed this for.

The call resolves to a token or null and never throws, so there is no error path to write. What it buys you is the device fingerprint as a hard identity key: fifty accounts driven from one browser through fifty residential addresses link into one cluster, and a confirmed abuse conviction on any one of them marks the rest. IP alone will not do that, because shared egress makes per-IP counts untrustworthy. The device fingerprinting guide covers placement in a single-page app.

2. Screen the signup in a Next.js route handler, before the account exists

// app/api/signup/route.ts
import { randomUUID } from "node:crypto";
import { Portreeve } from "portreeve";

export const runtime = "nodejs";

const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);

export async function POST(request: Request) {
  const { email, password, device_token } = await request.json();

  // Mint the id first, screen with it, then create the account with it.
  const userId = randomUUID();
  const ip = request.headers.get("x-forwarded-for")?.split(",")[0].trim();

  const result = await portreeve.verdict({
    event_type: "signup",
    external_user_id: userId,
    ip,
    device_token,
    email,
  });

  // ... step 3 continues in this handler
}

Minting the id before the screening call is the load-bearing part. It gives you one identifier that the verdict, your user row, your later feedback call, and the review webhook all agree on. Screen after the insert and you are either creating accounts you then have to delete, or reconciling two identifiers under a customer complaint.

export const runtime = "nodejs" is worth being explicit about. The server SDK targets Node 18.17+ and has zero runtime dependencies, and you are reading a secret from the environment on every request.

Resist the urge to move this into middleware.ts. You need the email and the device token out of the request body, and Next.js has been walking away from that file: in v16 the convention was renamed to proxy.js, and the docs now say plainly that "this feature is recommended to be used as a last resort." The route handler already runs once per signup, holds the parsed body, and is the only place with your database. Keep the check there.

3. Branch on allow, review, block, and degraded

  if (result.verdict === "block") {
    return Response.json({ error: "signup_unavailable" }, { status: 403 });
  }

  const user = await db.user.create({
    data: {
      id: userId,
      email,
      password_hash: await hash(password),
      trial_ends_at: addDays(new Date(), 14),
      review_pending: result.verdict === "review",
      screening_event_id: result.degraded ? null : result.id,
    },
  });

  return Response.json({ id: user.id }, { status: 201 });
}

Three decisions are in that block.

A block returns a generic 403. Do not name the reason in the response body; the reason codes are for your dashboard, and an error message that says which signal fired is free tuning feedback for whoever is testing your defences.

A review creates the account. The user gets their trial, the event lands in a queue, and review_pending is the column your product reads to hold an expensive feature, cap a quota, or delay an outbound email. That is the whole false-positive discipline: because review costs the end user nothing, the engine can flag aggressively and keep block for signals worth a hard decline. Handling verdicts has the full table.

Compare it to a binary system. Geocodio, describing how they keep their free tier sustainable, wrote that "high-risk signups are blocked and our team is notified via a dedicated Slack channel", and that they still receive "an average of one of these emails per month" from a real customer asking to be let in, down from their earlier system that blocked on repeat signups from one IP. That is a careful team, already tuned away from the crude version, and a binary decision still produces a steady trickle of people they have to apologise to. The queue exists so that trickle costs nothing.

The degraded guard is the third one. On a timeout or an outage the SDK fails open inside a 400 ms budget: you get allow, degraded: true, and a *_failopen reason code. Storing that as a screening result gives you a table full of rows claiming allow for accounts nobody ever evaluated, and eventually a chart built on that column reporting a suspiciously clean funnel. Store null and move on.

The other two trial moments

Signup is one of five moments. If your trial starts later than the account (they sign up, poke around, then click "Start trial"), screen that click too, and screen the conversion to paid:

await portreeve.verdict({ event_type: "trial_start", external_user_id: userId, ip, device_token });

trial_convert is the one event where ip is not required, because conversion often happens in a billing job rather than a browser request. Everything else about the call is identical.

While you are here: requiring a card to start the trial is the advice you will get in every thread on this, and for a small software business it is usually the wrong trade. It costs you real signups at the top of the funnel, and it stops nobody who is buying prepaid virtual cards in bulk. You give up conversion to filter out the least determined attackers. Free trial abuse goes through the economics.

4. Fire a test signup and watch the event land

Test mode is fully isolated from live, so you can throw whatever you want at it:

curl -X POST http://localhost:3000/api/signup \
  -H 'content-type: application/json' \
  -H 'x-forwarded-for: 203.0.113.42' \
  -d '{"email":"[email protected]","password":"hunter22"}'

Set the header yourself here, because a request straight to localhost arrives without one and ip would be undefined. Expect a 201 back, and a disposable-email reason code on the event. The verdict itself depends on what else the engine has seen from that address and device, so do not read a single allow as a failure. Open the dashboard in test mode: the event is on the Events page with its reason codes and the identity keys it linked on.

If nothing is there, either you started the server with a live key, or the request never reached the handler. Log result.id for one run and check the two against each other.

Then close the loop. When you confirm an account was abusive, report it, because a conviction marks the whole linked cluster rather than the one row you found:

await portreeve.feedback(eventId, "confirmed_abuse");

// or address the account, not one event
await portreeve.feedback({ kind: "external_user_id", value: userId }, "chargeback", "stripe dispute du_...");

Feedback is idempotent per event and outcome, so a retrying worker cannot double-count. Test mode and feedback covers the loop.

5. Revoke when a review comes back denied

Reviews resolve later. A denial reaches your server as a signed webhook, and your job is to disable the account without breaking on a redelivery.

// app/api/portreeve/webhook/route.ts
import { Portreeve } from "portreeve";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const rawBody = await request.text();

  let event;
  try {
    event = Portreeve.verifyWebhook(
      rawBody,
      request.headers.get("portreeve-signature"),
      process.env.PORTREEVE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response("invalid signature", { status: 400 });
  }

  if (event.type === "review.resolved") {
    if (event.resolution === "denied") {
      await db.user.updateMany({
        where: { id: event.external_user_id, disabled_at: null },
        data: { disabled_at: new Date(), review_pending: false },
      });
    } else {
      await db.user.updateMany({
        where: { id: event.external_user_id },
        data: { review_pending: false },
      });
    }
  }

  return new Response("ok", { status: 200 });
}

Read the body with request.text() and hand that exact string to the verifier. The signature is an HMAC-SHA256 over the raw bytes, so a round trip through JSON.parse and back will not verify. This is one place the App Router is easier than the old API routes: as the Next.js docs put it, "unlike API Routes with the Pages Router, you do not need to use bodyParser", so the raw body is what you get by default.

Deliveries retry until they get a 2xx, which makes idempotency mandatory rather than tidy. The disabled_at: null filter turns a second delivery into a zero-row update instead of a second disable with a new timestamp. Return 200 for event types you do not handle, too, or you will collect retries forever for something you were going to ignore anyway.

Verify it end to end by opening a test-mode event in the dashboard, denying it, and watching the row flip.

Going to production

The IP header. On Vercel the docs say the platform will "overwrite the X-Forwarded-For header and do not forward external IPs", specifically "to prevent IP spoofing", so the first entry is the client and the code above is correct as written. Self-hosted behind your own nginx or a CDN, that guarantee disappears: the leftmost entry is whatever the client typed, and taking it blindly means every velocity counter you own can be poisoned by a header. Count your real hops and take the entry that many positions from the right.

Fail mode. The default is fail open: a timeout returns allow with degraded: true rather than taking your signup form down with it. Keep that for signup. Fail-closed is a client-side option and it belongs on payment routes, where an unscreened window is money moving rather than a trial you can review tomorrow.

Keys. Swap sk_test_ for sk_live_ and pk_test_ for pk_live_, and point a live webhook endpoint at your production URL. Live counters start empty, so give the identity graph a week of real traffic before you conclude anything from a quiet queue.

What goes wrong

Treating review as a block. The most common integration bug is if (result.verdict !== "allow") return 403. It converts a free, reversible flag into a lost customer and throws away the reason the engine can afford to flag at all. block is a 403. review is a column.

Blocking on an email regex instead. A domain blocklist is the cheapest thing to build and the first thing to go stale; plus-addressing and dot tricks on a single Gmail account walk straight through it. Useful as one signal among several, useless as the whole gate. More on that in disposable email domains.

Double submits. Users tap the button twice and mobile clients retry on flaky networks. Each POST mints its own userId, so the second one is a second verdict, a second account, and a velocity spike you caused yourself. A unique constraint on email is the cheap fix; an idempotency key generated once in the form and sent with both attempts is the thorough one.

Screening after the insert. It feels safer because the code is simpler. It means every blocked signup is a row you have to clean up, and it puts the fraud check after the expensive part of the request.

Start with signup, leave it alone for a week, then read the queue before you tune anything. The queue tells you what your abuse looks like, which is rarely what you assumed while writing the handler.

Grab a test key and have this running locally: create a free account, no card required.

← Back to all posts