Skip to main content

sometechblog.com

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):

Cloudflare Turnstile captcha protection on SQLAI.ai