Skip to content
T
trueuser
← Integration guides

How to Protect Next.js Signups from Free Trial Abuse

Check email risk in your Next.js backend before granting trial credits. Start with a server-only helper, or give your coding agent the integration prompt below.

TrueUser team

Where should signup protection run?

Run the check in the server path that decides trial eligibility: a route handler, server action, or a durable signup job. Browser validation improves the form experience, but callers can bypass it. Keep your existing authentication, email confirmation, rate limits, and request protections.

TrueUser returns email characteristics and an allow, review, or block verdict based on your account rules. It does not prove identity or issue credits for you. Your application decides which benefits to unlock.

1. Add a server-only API helper

Create a key in your dashboard. Set TRUEUSER_API_KEY in your server environment and deployment secret store. Never prefix it with NEXT_PUBLIC_. Set TRUEUSER_TIMEOUT_MS to your latency budget; this example defaults to 3,000 ms, not a promised API response time.

Copy this file to lib/trueuser.ts. It validates the response, makes one request, and returns an application-owned pending state on failure. It sends only the email. Install the server-only package if your project does not already have it.

Download the TypeScript helper
// Copy to a server module (for example lib/trueuser.ts in Next.js).
import "server-only";

export type SignupCheck =
  | { status: "checked"; verdict: "allow" | "review" | "block" }
  | {
      status: "pending";
      reason: "configuration" | "input" | "http" | "response" | "unavailable";
    };

const flags = [
  "format_valid",
  "disposable",
  "public_domain",
  "relay_domain",
  "alias",
  "role_account",
  "spam",
] as const;
const scores = ["suspicious_pattern", "batch_cluster"] as const;
const isObject = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

export async function checkSignup(email: unknown): Promise<SignupCheck> {
  const key = process.env.TRUEUSER_API_KEY;
  const timeoutMs = Number(process.env.TRUEUSER_TIMEOUT_MS ?? "3000");
  if (
    !key ||
    !Number.isFinite(timeoutMs) ||
    timeoutMs < 100 ||
    timeoutMs > 30000
  ) {
    return { status: "pending", reason: "configuration" };
  }
  if (typeof email !== "string") return { status: "pending", reason: "input" };
  const address = email.trim();
  if (address.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) {
    return { status: "pending", reason: "input" };
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const response = await fetch("https://trueuser.dev/api/v1/verify", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email: address }),
      signal: controller.signal,
      cache: "no-store",
    });
    if (!response.ok) return { status: "pending", reason: "http" };
    const data: unknown = await response.json();
    if (!isObject(data) || data.email !== address || !isObject(data.signals)) {
      return { status: "pending", reason: "response" };
    }
    const signals = data.signals;
    if (
      !flags.every((name) => typeof signals[name] === "boolean") ||
      !scores.every(
        (name) =>
          typeof signals[name] === "number" &&
          Number.isFinite(signals[name]) &&
          (signals[name] as number) >= 0 &&
          (signals[name] as number) <= 1,
      ) ||
      typeof data.reasoning !== "string" ||
      typeof data.latency_ms !== "number" ||
      !Number.isFinite(data.latency_ms) ||
      data.latency_ms < 0 ||
      (data.verdict !== "allow" &&
        data.verdict !== "review" &&
        data.verdict !== "block")
    )
      return { status: "pending", reason: "response" };

    // This application's policy still rejects invalid format even if account rules allow it.
    return {
      status: "checked",
      verdict: signals.format_valid ? data.verdict : "block",
    };
  } catch {
    return { status: "pending", reason: "unavailable" };
  } finally {
    clearTimeout(timer);
  }
}

The default API mode can use AI and recent signup context. Set local_mode: true in the request body if you want only local checks without an AI call or credit charge; its batch score is zero. In default mode, an AI failure may still return HTTP 200 with local signals. There is currently no structured degraded flag, so an allow result is not a guarantee that AI ran. See the API reference.

2. Enforce the decision before granting benefits

  • Allow: continue normal eligibility checks, including email confirmation and previous trial grants.
  • Review: persist a pending eligibility decision and route the user to a real review or additional verification flow.
  • Block: withhold trial benefits and offer a neutral message with a recovery path.
  • Pending: keep expensive benefits unavailable and let the user retry or contact support. A service error should not silently create a free-credit grant.

Call await checkSignup(email) from your trusted server handler, then persist eligibility using your own schema. Grant credits and record the grant together in a database transaction with a unique key such as the account and trial-offer ID. A prior read alone is insufficient: two concurrent callbacks can both observe no grant. Do not hold a database transaction open while waiting for the external API.

This helper does not provide a credit ledger or a review queue. Connect those to your existing product before enabling trial grants. Protect the retry route with authentication and rate limits. Keep sign-in available to existing users when a risk service fails.

3. Test your application policy

Mock the API for allow, review, block, 401, 5xx, timeout, and malformed-response cases. Verify that only eligible accounts receive benefits and that concurrent callbacks create a single grant. Also test your actual retry/review flow. Use synthetic addresses in tests and keep raw emails, IPs, and API keys out of analytics.

Measure both abuse cost and legitimate trial conversion. Aliases, Gmail addresses, privacy relays, and shared IPs can belong to real customers. A risk score should support a proportionate policy and a way to correct mistakes.

Build with your coding agent

Copy a prompt. Add signup protection.

Paste this into Claude Code, Codex, Cursor, or your preferred coding agent in your project. It covers the API contract, server-side secrets, failure handling, and trial-credit safeguards.

Read the Next.js guide →

Add your real API key to your server environment after reviewing the generated changes.

Next.js signup protection FAQ

Can my coding agent add this to an existing auth flow?

Yes. Use the prompt above in your repository so it can inspect your framework, auth callbacks, credit ledger, and tests. Review the changes and configure the server secret before deployment.

Should I block every email alias?

No. Plus addressing and privacy relays have legitimate uses. Combine signals with account history and your eligibility policy; do not assume an alias proves duplicate accounts.

Does this stop AI free-credit farming by itself?

No. Signup checks are one layer. Enforce idempotent grants, account spending caps, and server-side usage checks. Read the free trial abuse guide for the wider policy.