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.
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/core0.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.
- Set
LOGINLLAMA_API_KEYto the test key in development. - Make a user with the email
[email protected]. - 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.