Login Risk Scoring for Auth.js (NextAuth)

Score every Auth.js Credentials sign-in for risk and refuse the dangerous ones before a session exists. Wrap authorize in one line.

guidesauthjsnextauth

Auth.js checks the password. It does not ask whether the login looks right. A correct password from a Tor exit node, a country the user has never been to, or a bot that has just tried four thousand other accounts still gets a session.

The Credentials provider runs your authorize function on every sign-in. That is where to ask LoginLlama for a risk score. You get the answer before Auth.js makes the session, so you can refuse the sign-in outright.

What you need

  • An Auth.js app (NextAuth v5, or anything on @auth/core 0.40 or newer) with a Credentials provider.
  • A LoginLlama API key. New accounts get 1,000 free checks.

Put the key in your environment:

LOGINLLAMA_API_KEY=your-key-here

Wrap authorize

npm install authjs-loginllama
// auth.ts
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { withLoginLlama } from 'authjs-loginllama';
import { verifyPassword } from './users'; // your own user lookup

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Credentials({
      credentials: { email: {}, password: {} },
      authorize: withLoginLlama(({ email, password }) =>
        verifyPassword(String(email), String(password))
      )
    })
  ]
});

That's the whole setup. The same code works in SvelteKit, Express and anything else built on @auth/core.

Tell the user why

A refused sign-in fails with RiskySignIn. Auth.js sends the user back to the sign-in page with ?code=risky_sign_in in the URL, so you can say what happened:

// app/login/page.tsx
export default async function Login({ searchParams }) {
  const { code } = await searchParams;
  return (
    <>
      {code === 'risky_sign_in' && (
        <p>
          This sign-in looked unusual, so we stopped it. Reset your password or
          contact support.
        </p>
      )}
      {/* your form */}
    </>
  );
}

How it works

Your authorize runs first. When it accepts the password, the wrapper sends the email, IP address and user agent to LoginLlama. It gets back a score from 0 to 10 with reason codes such as known_tor_exit_node, known_vpn, impossible_travel_detected, new_login_location and known_bot. At 8 or above it throws, and Auth.js never makes the session.

When authorize returns null the wrapper reports a failed attempt. That is how LoginLlama spots someone guessing passwords, and how it learns each user's usual devices and places. Every attempt shows up in the dashboard with its score and reasons.

Three choices are yours to change.

The threshold. LoginLlama calls anything above 5 risky. The wrapper refuses at 8, so you only act on the clearest cases at first. Pass withLoginLlama(authorize, { blockAt: 6 }) once you have watched real scores for a week.

The identity. The wrapper names the user by the email field. If your users sign in with a username, pass identify: (c) => String(c.username).

Failing open. If LoginLlama is down or takes longer than two seconds, the sign-in goes through. A fraud check should never lock your users out because of an outage on our side or yours.

Behind a proxy

The wrapper reads the IP address from x-forwarded-for. Vercel, Cloudflare, Fly and most load balancers set it. If your host does not, every sign-in will look like it came from the same place, so check the dashboard after your first few sign-ins.

What about GitHub and Google sign-ins?

Only the Credentials provider hands your code a password to check. OAuth providers have already authenticated the user on their side, and Auth.js gives you no hook before the session that has the request's IP address. Most of the account takeover risk sits with passwords, which is where this covers you.

Test it without a real attack

Every team has a test key on the API keys page. Test checks are free, and they take their answer from tags in the email address rather than the real IP address.

  1. Set LOGINLLAMA_API_KEY to the test key in development.
  2. Make a user with the email [email protected].
  3. Sign in with the right password. LoginLlama answers 9, and you land back on the sign-in page with ?code=risky_sign_in.

[email protected] returns that reason code instead. The full list of tags is in the test mode docs.

Or write it yourself

The wrapper is under 100 lines and MIT licensed, on GitHub. If you'd rather own the code, copy it next to your auth.ts. It needs only fetch.

Create a free account to get an API key.