Protect NextAuth.js input fields with Cloudflare Turnstile captcha
Received a complaint email from Amazon SES (Simple Email Service) pointing out that my SES account could be closed because the complaint rate exceeded 0.51%. Bots were apparently abusing the input field to send signin emails to unknowing email addresses. The only exposed input field in my NextJS app is on a magic-link form, see it here.
Cloudflare offers an easy and free captcha solution with Turnstile. To get started you need 2 keys, a site key and secret key. You can see how to get them here. Once you’ve been issued the keys, you can integrate it with the magic-link form.
Embed Cloudflare Turnstile library on the magic-link form and include cfTurnstileToken
in NextAuth.js’s signIn
function (custom signin page on /signin
):
'use client';
import { Button } from '@/components/ui/Button';
import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';
import Script from 'next/script';
import React from 'react';
export const Email = () => {
const [email, setEmail] = React.useState('');
const [isLoading, setIsLoading] = React.useState(true);
const searchParams = useSearchParams();
React.useEffect(() => {
globalThis.window.javascriptCallback = () => {
// Enable input field only after Turnstile has succesfully loaded
setIsLoading(false);
};
}, []);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const callbackUrl = searchParams?.get('next') ?? searchParams?.get('callbackUrl') ?? '/app';
setIsLoading(true);
const data = new FormData(e.currentTarget);
const cfTurnstileToken = data.get('cf-turnstile-response');
// Add the Turnstile token used to verify user to NextAuth.js's sign-in function.
void signIn('email', { email, callbackUrl, cfTurnstileToken });
}}
>
<div>
<input
type="email"
placeholder="Add email and get sign in link..."
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
{/* Embed Turnstile library */}
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
<Button type="submit" variant="outline" disabled={isLoading}>Sign in</Button>
</div>
{/* Add sitekey and callback for when the token is ready */}
<div className="cf-turnstile" data-sitekey="YOUR_PUBLIC_SITE_KEY" data-callback="javascriptCallback" />
</form>
);
};
When the Turnstile token is included in the NextAuth.js signIn
function we can access it when the user makes an API request to /api/auth/signin/email
. First, however, we need to make the req
available in the signin
function. This is done in src/pages/api/auth/[...nextauth].ts
by exporting a function that has req
and res
as arguments. Once that is done we can access cfTurnstileToken
and verify the request:
import { type NextApiRequest, type NextApiResponse } from 'next';
import NextAuth, { type AuthOptions } from 'next-auth';
import EmailProvider from 'next-auth/providers/email';
// Omitted some configuration for brevity...
const getOptions = (req: NextApiRequest): AuthOptions => {
return {
providers: [EmailProvider({ server: process.env.AWS_SMTP_URL, from: 'noreply@sqlai.ai' })],
pages: {
signIn: '/signin',
},
callbacks: {
async signIn(props) {
// Only access requests to the email sign API in other, e.g. Google Auth doesn't have this issue.
if (req.url?.startsWith('/api/auth/signin/email')) {
const { cfTurnstileToken } = req.body;
if (!cfTurnstileToken) {
return false; // Denied
}
const ip = req.headers['x-forwarded-for']; // This is Vercel specific
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
body: JSON.stringify({
secret: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY,
response: cfTurnstileToken,
remoteip: ip,
}),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const outcome = await result.json();
if (outcome.success) {
return true; // Accepted since token is valid
}
return false; // Denied
}
return true; // Accept other signins as default
},
},
};
};
export default (req: NextApiRequest, res: NextApiResponse) => NextAuth(req, res, getOptions(req));
That’s it. It only required changing 2 files. After implementing it I haven’t recieved a single complain from Amazon SES.
The end result looks like this (gif):