Guide · Clerk + Next.js App Router

Clerk tells you a user was created.
By then it is created.

Most people reach for the user.created webhook, and it is the wrong tool for stopping anything: the account and the session already exist by the time it fires, delivery is asynchronous, and a non-2xx response only makes Clerk retry. There is exactly one place a Clerk signup can still be refused, and it is not a Clerk hook at all. This guide covers that path and the webhook path, because you want both.

01Install

npm install @clerk/nextjs usehuma

Stay on @clerk/nextjs 6.23.3 or later: earlier versions carry a signature-verification advisory for webhooks. Get a free useHUMA key at humaverify.com/signup.

# .env.local — your keys from dashboard.clerk.com
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_••••••••
CLERK_SECRET_KEY=sk_test_••••••••
CLERK_WEBHOOK_SIGNING_SECRET=whsec_••••••••
HUMA_API_KEY=huma_live_••••••••

02The decision that shapes everything

Clerk gives you two ways to render a signup, and they differ in exactly the way that matters here.

The prebuilt <SignUp /> component exposes no submit to intercept. With it you cannot refuse a signup before it happens, only react afterwards. In a custom flow you are the one calling signUp.password(), so declining to call it is the whole mechanism. Nothing clever, and nothing that a bot can route around by loading a different page, because Clerk never hears from you.

Why not gate the page instead? A conditional render that hides the form until a check passes lives in your React tree, so anything that posts to Clerk directly never runs it. It also makes every real visitor wait while it decides. The gate belongs on the call to Clerk, not on the pixels.

03Your server holds the key

// app/api/huma-gate/route.ts
// Your server holds the key. It never reaches the browser.
export async function POST(req: Request) {
  const { features } = await req.json();

  const res = await fetch("https://humaverify.com/api/v1/verify", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.HUMA_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      // No Clerk user exists yet, so there is no Clerk id to send.
      userId: `clerk_signup_${crypto.randomUUID()}`,
      sessionData: features,
    }),
    signal: AbortSignal.timeout(5000),
  });

  // Our outage must not become your outage.
  if (!res.ok) return Response.json({ allow: true, degraded: true });

  const v = await res.json();
  return Response.json({ allow: v.human && v.agent?.is_agent !== true });
}

04The blocking path

// app/sign-up/page.tsx — custom flow, Clerk Core 3
"use client";
import { useSignUp } from "@clerk/nextjs";
import { useRouter } from "next/navigation";

export default function Page() {
  const { signUp, fetchStatus } = useSignUp();
  const router = useRouter();

  async function handleSubmit(formData: FormData) {
    // huma.js has been collecting since page load. No waiting, no spinner.
    const features = (window as any).Huma?.debug?.().features ?? {};

    const gate = await fetch("/api/huma-gate", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ features }),
    }).then((r) => r.json());

    // A fail here means no Clerk user is created by this flow at all.
    if (!gate.allow) return;

    await signUp.password({
      emailAddress: formData.get("email") as string,
      password: formData.get("password") as string,
      // Carried so the webhook can re-check server side.
      unsafeMetadata: { huma: features },
    });
    await signUp.verifications.sendEmailCode();
  }

  async function handleVerify(formData: FormData) {
    const { error } = await signUp.verifications.verifyEmailCode({
      code: formData.get("code") as string,
    });
    if (error) return;

    // Gate on status, not merely on the absence of an error.
    if (signUp.status !== "complete") return;

    // finalize takes a navigate CALLBACK. Passing router.push does not work.
    await signUp.finalize({
      navigate: ({ session, decorateUrl }) => {
        if (session?.currentTask) return; // e.g. an org-selection task pending
        router.push(decorateUrl("/"));
      },
    });
  }

  return (
    <form action={handleSubmit}>
      <input name="email" type="email" />
      <input name="password" type="password" />
      {/* Must exist before password() is called, or Clerk falls back to an
          invisible widget that can block a suspected bot with no recourse. */}
      <div id="clerk-captcha" />
      <button disabled={fetchStatus === "fetching"}>Sign up</button>
    </form>
  );
}

The collector has been running since page load, so there is no spinner and no wait. On a fail no Clerk user is created, no session is issued, and there is nothing to clean up.

05The webhook path, for what bypasses the form

SSO, an invite link, or the prebuilt component in some other corner of your app all reach Clerk without passing through the flow above. The webhook cannot prevent those, but it can act on them.

// app/api/webhooks/clerk/route.ts
// Needs @clerk/nextjs 6.23.3 or later: earlier versions carry a
// signature-verification advisory.
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import { clerkClient } from "@clerk/nextjs/server";

export async function POST(req: Request) {
  let evt;
  try {
    evt = await verifyWebhook(req); // reads CLERK_WEBHOOK_SIGNING_SECRET
  } catch {
    return new Response("bad signature", { status: 400 });
  }

  if (evt.type !== "user.created") return new Response("ok");

  const userId = evt.data.id;
  // unsafeMetadata arrives snake_cased in the webhook payload.
  const features = (evt.data.unsafe_metadata as Record<string, unknown>)?.huma;

  // No signals at all is itself worth noticing: the prebuilt <SignUp/>, an
  // SSO path, or something that skipped your form. Decide policy here rather
  // than silently allowing.
  if (!features) return new Response("ok");

  const res = await fetch("https://humaverify.com/api/v1/verify", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.HUMA_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ userId, sessionData: features }),
    signal: AbortSignal.timeout(5000),
  });
  if (!res.ok) return new Response("ok"); // fail open

  const v = await res.json();
  if (v.human === false || v.agent?.is_agent) {
    const clerk = await clerkClient();
    // The verdict goes in publicMetadata, which the client cannot forge.
    await clerk.users.updateUserMetadata(userId, {
      publicMetadata: { humaHuman: false, humaAt: new Date().toISOString() },
    });
    await clerk.users.banUser(userId); // blocks future sign-in
  }
  return new Response("ok");
}
Why the verdict moves to publicMetadata: unsafeMetadata is writable by the client at any time, which is what unsafe means. It is fine as a carrier for raw signals and useless as a place to keep a decision. Write the decision from your server, where it cannot be forged.

What bites

Why this instead of a CAPTCHA

A CAPTCHA charges every legitimate user for the existence of bots that increasingly solve puzzles anyway. This charges nobody: the person types, and the decision happens on your server from what already happened. It also answers a question a CAPTCHA does not, which is whether the session was an AI agent driving a real browser rather than a script. What it does not catch is written down at /limits, before you build on it.

Clerk for auth, useHUMA for humanity.

Free plan: 1,000 verifications a month, no card, no expiry, AI-agent verdict included.

Get your API key →

Clerk API names and signatures on this page were checked against Clerk's live documentation on 26 Aug 2026. If something has drifted, tell us at team@humaverify.com and it gets fixed.

Help