Guide · Auth.js / NextAuth

signIn can say no.
It just cannot see the request.

Returning false from callbacks.signIn halts the flow and returns AccessDenied. Returning a string redirects instead, which is usually the better answer. The constraint that shapes everything else: the callback receives only user, account, profile, email and credentials. No request, no headers, no body.

01One helper, used by both paths

// lib/huma.ts
export async function verifyHuman(userId: string, sessionData: unknown) {
  try {
    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 }),
      signal: AbortSignal.timeout(5000),
    });
    return res.ok ? await res.json() : null;
  } catch {
    return null; // fail open: our outage is not your outage
  }
}

02Credentials: the easy half

On a Credentials provider the check belongs in authorize, not in signIn. Its first parameter is the unfiltered POST body, so an extra field arrives intact, and returning null fails the sign-in.

// auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verifyHuman } from "@/lib/huma";

export const { handlers, auth, signIn } = NextAuth({
  providers: [
    Credentials({
      // This object only renders the built-in sign-in form. It does NOT
      // filter what authorize() receives, so humaSession arrives intact.
      credentials: { email: {}, password: {} },

      async authorize(credentials) {
        let features: unknown = null;
        try {
          features = credentials.humaSession
            ? JSON.parse(String(credentials.humaSession))
            : null;
        } catch {
          features = null; // malformed input must fail open, not hard-fail
        }

        if (features) {
          const v = await verifyHuman(String(credentials.email), features);
          if (v && (v.agent?.is_agent || v.human === false)) return null;
        }
        return await lookUpUser(credentials);
      },
    }),
  ],
});
Why the extra field works. The credentials object in the provider config is a form-rendering hint for the built-in sign-in page. It does not filter what authorize receives: Auth.js hands over the whole parsed body. And the signIn helper forwards every option other than redirect and redirectTo into that body, so there is no plumbing to write.
// The sign-in call. Every option other than redirect and redirectTo
// is forwarded into the POST body, so no extra plumbing is needed.
import { signIn } from "next-auth/react";

await signIn("credentials", {
  email,
  password,
  humaSession: JSON.stringify(window.Huma.debug().features),
});

03OAuth: the half with no channel

There is no field to carry signals through an OAuth redirect, so write them to a cookie before the redirect and read it in signIn. If the cookie is missing, allow: an absent cookie is far more likely to be a privacy setting than a bot.

// auth.ts — the OAuth half
import { cookies } from "next/headers";

callbacks: {
  // Params are { user, account, profile, email, credentials } only.
  // There is no request here, which is the whole constraint.
  //   false  => blocks with AccessDenied
  //   string => redirects there instead
  async signIn({ user, account }) {
    if (account?.type !== "oauth" && account?.type !== "oidc") return true;

    const raw = (await cookies()).get("huma")?.value;
    if (!raw) return true; // nothing collected: never punish a quiet visitor

    let features: unknown;
    try {
      features = JSON.parse(raw);
    } catch {
      return true; // a malformed cookie must fail open, not hard-fail
    }

    const v = await verifyHuman(user.id ?? user.email ?? "unknown", features);
    // A redirect gives a real person a way back. false gives them a wall.
    if (v && !v.human) return "/verify-human";
    return true;
  },
}

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.

Next.jsWordPressClerkAuth0Supabase AuthFirebase Auth

Auth.js for auth, useHUMA for humanity.

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

Get your API key →

Callback signatures and blocking behaviour on this page were checked against Auth.js's live documentation and source on 26 Aug 2026, for v5. If something has drifted, tell us at team@humaverify.com and it gets fixed.

Help