Stops new accounts on disposable or dead domains from inside the signIn callback. Returning users skip the check.
The code
1 Keep the key on the server
MAILVET_KEY=mv_live_your_keySet it wherever your server reads its environment. Never put it in client code.
2 Put the check in one place
lib/mailvet.ts
// The fields this code reads. The response has more: mailvet.dev/docs
export type Check = {
verdict: "valid" | "risky" | "invalid";
disposable: boolean;
did_you_mean: string;
};
// null means no answer: MailVet is down or slow, the quota is
// used up, or the key is wrong. Callers let the signup through.
export async function checkEmail(email: string): Promise<Check | null> {
try {
const res = await fetch(
"https://mailvet.dev/v1/check?email=" + encodeURIComponent(email),
{
headers: { Authorization: `Bearer ${process.env.MAILVET_KEY}` },
signal: AbortSignal.timeout(5000),
},
);
if (!res.ok) {
console.error(`mailvet: HTTP ${res.status}`);
return null;
}
return (await res.json()) as Check;
} catch (err) {
console.error("mailvet: no answer", err);
return null;
}
}3 Add the signIn callback
auth.ts
import { PrismaAdapter } from "@auth/prisma-adapter";
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import { checkEmail } from "@/lib/mailvet";
import { prisma } from "@/lib/prisma";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [GitHub],
callbacks: {
async signIn({ user, account, email }) {
// Magic links call this twice. Check at the request, not the click.
if (account?.type === "email" && !email?.verificationRequest) return true;
if (!user.email) return true;
// Returning user: no check spent, and no lockout if their
// domain gets listed later. Swap this for your own user lookup.
const known = await prisma.user.findUnique({
where: { email: user.email },
});
if (known) return true;
const check = await checkEmail(user.email);
if (check?.did_you_mean || check?.verdict === "invalid") {
return "/signup?error=email";
}
if (check?.verdict === "risky" && check?.disposable) return "/signup?error=disposable";
return true;
},
},
});What to do with the answer
Returning a string redirects there, so your /signup page reads error and explains. Returning false works too, but it sends people to the generic error page. Role addresses and a risky from a DNS failure return true.
- valid Let it through.
- risky Read
disposable. If it’s true, reject. If false, it’s a role address or DNS couldn’t be reached, so let it through. - invalid Bad format, or the domain has no mail server. Reject.
did_you_mean is empty or holds a domain, such as gmail.com for gamil.com. It doesn’t change the verdict. A known typo can come back valid because squatters give those domains a mail server, so check it first. See the docs for every field.
If MailVet is down, the signup goes through
checkEmail returns null when there's no answer, the ?. checks all miss, and the callback returns true. The sign-in goes through.
A 401 or a 429 takes the same path, so a bad key or a used-up quota means addresses pass unchecked. Watch your logs for it.
Watch out
- This callback runs on every sign-in, not only the first. That's why the user lookup comes first. Without it you'd spend a check per login and could lock out an existing user.
- Credentials sign-ins are not signups. If you have your own registration route, run
checkEmailthere too. - Auth.js says it's now part of Better Auth and has a migration guide. This code is for
next-auth@beta(v5), the version its install page uses.
Sources
Compared with these on November 5, 2026. If they’ve changed since, trust them over this page.