Skip to content

Checking emails in the Auth.js (NextAuth) signIn callback

· 6 min read · Tutorials

To check emails in Auth.js (NextAuth), put the check in the signIn callback: read user.email, ask an email-classification API whether the address is disposable or machine-generated, and return true to continue, false to deny, or a URL string to redirect the user to a page that explains why. The callback runs for OAuth providers and for magic-link email providers, where you can block the address before the verification email is ever sent. The code below targets Auth.js v5 in the Next.js App Router.

How the signIn callback behaves

From the Auth.js signIn callback reference:

  • Return true and the sign-in continues.
  • Return false (or throw) and the flow stops, redirecting to the error page with error=AccessDenied.
  • Return a string and the user is redirected to that URL.
  • For email providers, the callback's email argument has verificationRequest: true on the first call, which happens before the magic link is sent. When the user clicks the link, the callback runs again without it.
  • For OAuth providers, profile holds the provider's raw profile, and user.email holds the email Auth.js mapped from it.

Two consequences shape the code. Returning false produces a generic "access denied" page, which tells a real user with a typo or a throwaway address nothing useful, so we redirect with a reason instead. And because the callback runs on every sign-in, not only the first, the lookup has to be cheap, cached and tolerant of failure.

A note on versions: as of 2026, v5 is still published under the beta tag (npm install next-auth@beta), and the Auth.js site says the project is now part of Better Auth. The callback contract below is the documented v5 one.

What to deny

The helper calls pingutil's /v1/lookup with skip_enrichment: true, which returns category, email_signals and a verdict without running company enrichment.

  • Deny when verdict.is_spam is true: disposable domains, reputation-flagged domains and randomized local parts.
  • Allow verdict.low_trust addresses. Shared inboxes like info@ belong to real companies; role-based email addresses covers when to flag them instead.
  • Allow free webmail. Gmail users are real users. If your product is B2B-only, that's a separate policy decision, not a fraud check.

If you're still deciding between hard blocks and soft flags, should you block disposable emails or flag them lays out the trade-off.

The lookup helper

This file runs only on the server. The server-only import makes the build fail if a client component ever imports it, which keeps PINGUTIL_KEY out of the browser bundle.

// lib/email-check.ts
import 'server-only';

type Verdict = { is_spam: boolean; low_trust: boolean; reason: string | null };
export type Screen =
  | { kind: 'checked'; verdict: Verdict }
  | { kind: 'invalid' }
  | { kind: 'unavailable'; why: string };

const LOOKUP_TIMEOUT_MS = 2000;
const CACHE_TTL_MS = 10 * 60 * 1000;

// Per server instance. Users retry, and returning users sign in again.
const cache = new Map<string, { screen: Screen; expires: number }>();
let backoffUntil = 0;

export async function screenEmail(rawEmail: string): Promise<Screen> {
  const email = rawEmail.trim().toLowerCase();
  const hit = cache.get(email);
  if (hit && hit.expires > Date.now()) return hit.screen;
  if (Date.now() < backoffUntil) return { kind: 'unavailable', why: 'rate_limited' };

  let res: Response;
  try {
    res = await fetch('https://pingutil.com/v1/lookup', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ input: email, skip_enrichment: true }),
      signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
      cache: 'no-store',
    });
  } catch (err) {
    const why = err instanceof Error && err.name === 'TimeoutError' ? 'timeout' : 'network';
    return { kind: 'unavailable', why };
  }

  if (res.status === 429) {
    const body = (await res.json().catch(() => ({}))) as { retry_after_seconds?: number };
    backoffUntil = Date.now() + (Number(body.retry_after_seconds) || 60) * 1000;
    return { kind: 'unavailable', why: 'rate_limited' };
  }
  if (res.status === 400) return remember(email, { kind: 'invalid' });
  if (!res.ok) return { kind: 'unavailable', why: `http_${res.status}` };

  const data = (await res.json()) as { verdict: Verdict };
  return remember(email, { kind: 'checked', verdict: data.verdict });
}

function remember(email: string, screen: Screen): Screen {
  if (cache.size > 5000) cache.clear();
  cache.set(email, { screen, expires: Date.now() + CACHE_TTL_MS });
  return screen;
}

Only real answers are cached. Timeouts and outages are not, so the next attempt tries again. The cache: 'no-store' option keeps Next.js from applying its own fetch caching to a POST that should always reflect the current list. For longer-lived caching across instances, see caching domain lookups.

The callback

// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Resend from 'next-auth/providers/resend';
import { screenEmail } from './lib/email-check';

const FAIL_CLOSED = process.env.EMAIL_CHECK_FAIL_CLOSED === 'true';

export const { handlers, auth, signIn, signOut } = NextAuth({
  // adapter: ...  (email providers need a database adapter to store tokens)
  providers: [GitHub, Resend({ from: 'login@yourapp.com' })],
  callbacks: {
    async signIn({ user, account, email }) {
      // Email providers call signIn before sending the link (verificationRequest: true)
      // and again when the link is clicked. The first call is the one that matters.
      if (account?.type === 'email' && !email?.verificationRequest) return true;

      const address = user.email;
      if (!address) {
        // The provider returned no email for this account.
        return '/auth/blocked?reason=no_email';
      }

      const screen = await screenEmail(address);

      if (screen.kind === 'invalid') return '/auth/blocked?reason=invalid';
      if (screen.kind === 'checked') {
        if (!screen.verdict.is_spam) return true;
        const reason = screen.verdict.reason === 'disposable_domain' ? 'disposable' : 'not_allowed';
        return `/auth/blocked?reason=${reason}`;
      }

      console.warn(JSON.stringify({ msg: 'email check unavailable', why: screen.why, provider: account?.provider }));
      return FAIL_CLOSED ? '/auth/blocked?reason=try_later' : true;
    },
  },
});

Swap Resend for whichever email provider you use (Nodemailer, Postmark and others follow the same pattern), and keep the route handler from the Auth.js installation guide at app/api/auth/[...nextauth]/route.ts. Put PINGUTIL_KEY in .env.local next to AUTH_SECRET. Neither should have a NEXT_PUBLIC_ prefix.

Whether to block when the provider sends no email is a product decision. If your app needs an email for every account, redirecting is right. If it doesn't, return true instead.

The blocked page

The redirect target is an ordinary page. Keep the messages short and keep the specific reason for the one case a real user can fix: a temporary address.

// app/auth/blocked/page.tsx
const MESSAGES: Record<string, string> = {
  disposable: 'Temporary email addresses are not allowed. Please sign in with a permanent address.',
  invalid: 'That email address does not look valid.',
  no_email: 'Your account did not share an email address with us. Try another sign-in method.',
  try_later: 'Sign-ins are briefly unavailable. Please try again in a minute.',
  not_allowed: 'This email address cannot be used to sign in.',
};

export default async function Blocked({
  searchParams,
}: {
  searchParams: Promise<{ reason?: string }>;
}) {
  const { reason } = await searchParams;
  return <p>{MESSAGES[reason ?? ''] ?? MESSAGES.not_allowed}</p>;
}

searchParams is a Promise in Next.js 15 and later, hence the await. Unknown reasons fall back to the generic message, so nobody can put arbitrary text on your page through the query string. All three files type-check under strict against next-auth@5.0.0-beta.32.

Returning users and the rate limit

Because signIn runs on every sign-in, a returning user costs a lookup too (once per cache window per instance). pingutil allows 60 lookups per minute per key. That's comfortable for most apps, but an app with a burst of logins after an email campaign could hit it. On 429 the helper backs off for retry_after_seconds and fails open, so users don't notice; you'll just see rate_limited in the logs.

If you'd rather screen only new accounts, look the user up in your database first and skip the API when they already exist. With a database adapter, an OAuth user who has signed in before has a stored account row you can query by account.provider and account.providerAccountId. The trade-off: a domain that becomes disposable later won't be caught at login. That's usually fine, since sign-up is where throwaway accounts come from.

Fail open, on purpose

When the API times out, errors or rate-limits, the callback returns true and logs the reason. Setting EMAIL_CHECK_FAIL_CLOSED=true redirects to a "try again" page instead, which means every sign-in fails during an outage, including your existing paying users. That's rarely what you want for a login flow.

The same callback pattern works in a server action if you have your own signup form; validating signup emails in a Next.js server action shows that version. The response fields are documented in the API reference.

Questions people ask

What should the NextAuth signIn callback return to block a user?
Return false to send the user to the error page with error=AccessDenied, or return a URL string to redirect them somewhere specific. Returning true lets the sign-in continue.
Does the signIn callback run for magic-link (email) sign-ins?
Yes, twice. The first call has email.verificationRequest set to true, before the link is sent. The second call happens when the user clicks the link. Checking on the first call stops the email from going out at all.
Does the signIn callback run only for new users?
No. It runs on every sign-in attempt, for new and returning users. If you only want to screen new accounts, check whether the user already exists in your database before calling the API.