How to Protect Auth Forms with Cloudflare, Supabase SSR, and Astro Actions

When building authentication into your app, consider how to secure it from bots and malicious actors. This blog discusses how to do that using Cloudflare Turnstile in a Supabase and Astro project.

💡
This blog builds upon this tutorial, which covers the necessary setup and the auth form. You can check the code here, the new changes added will be available in the add cloudflare branch.

Prerequisites

This blog assumes that you:

What is Cloudflare Turnstile?

Turnstile is Cloudflare’s replacement for CAPTCHAs, which are visual puzzles you must solve to differentiate between genuine users and bots.

CAPTCHAs are visually clunky, annoying, and sometimes really hard to solve. They reduce the quality of the user experience and are generally inaccessible.

In comparison, Turnstile does the same job of detecting malicious activity without requiring users to solve puzzles. And Turnstile looks prettier, in my opinion 😅.

What is Supabase?

Supabase is an open-source Backend-as-a-Service that builds upon Postgres. It provides common key features such as authentication, real-time, edge functions, storage, and more.

Supabase offers a hosted version that makes building and scaling production-ready apps supa easy and a self-hostable version that gives users full control.

What is Astro?

Astro is a UI-agnostic web framework. It renders server-first by default and can be used with any UI framework, including Astro client components.

Why Should You Protect Auth Forms?

Authentication protects sensitive resources from unauthorized access, making them the primary target that bots and malicious actors attempt to breach. Therefore, you should take extra precautions to deter them from successfully compromising your application.

Setting Up Supabase

To get started, you will need a Supabase account. Then, follow the prompts to create a project. Go to the Authentication tab in the sidebar, click the Sign In / Up tab under Configuration, and enable user sign-ups.

💡
Please use this code as the base, as this blog assumes you already have the forms and the project set up with authentication.

Next, go to the Auth Protection tab under the Configuration on the same page, turn on Captcha protection, and choose Cloudflare as the provider.

Next, you will need to set up a Cloudflare account to get Cloudflare credentials.

Setting Up Cloudflare Turnstile

Log in or register a Cloudflare account, click the Turnstile tab in the sidebar, and then click the “Add widget” button.

Next, name your widget, add “localhost” as the hostname, and leave all other settings as is. Then, create it. Cloudflare will then show you your site key and secret key.

This is a screenshot of the "Add Widget" page for Cloudflare's Turnstile feature. The interface allows users to name the widget, manage hostnames, and choose a widget mode from "Managed," "Non-interactive," or "Invisible." There is also an option to opt for pre-clearance for the site. A graphic of a document and code snippet is displayed in the center. Buttons for adding hostnames, canceling, and creating are located at the bottom.

Copy the secret key and add it to the Supabase dashboard under the Auth protection section.

The image shows a webpage interface for "Attack Protection" settings. It includes options for "Bot and Abuse Protection," with a toggle switch for enabling captcha protection, a dropdown to choose the captcha provider (set to "Turnstile by Cloudflare"), and a field for entering a captcha secret. A "Save changes" button is visible at the bottom.

Connect the Frontend

In your project’s .env file, add the cloudflare site key provided from the step above like so:

TURNSTILE_SITE_KEY=<YOUR_TURNSTILE_SITE_KEY>

Then in the Layout.astro file, add this script just above the closing </head> tag to initiate Cloudflare’s turnstile:

    <script
            src="https://challenges.cloudflare.com/turnstile/v0/api.js"
            async
            defer></script>

You can read more about Cloudflare Turnstile to understand the flow better.

Updating the index page

Then update the index.astro page to include this div right above the submit button:

<div class="cf-turnstile" data-sitekey={apiKey}></div>

Now add this line within the frontmatter at the top of the same page:

const apiKey = import.meta.env.TURNSTILE_SITE_KEY;

Finally, adjust the script tag to account for the turnstile logic. It should look like this:

<script>
    import { actions } from "astro:actions";

    declare global {
        interface Window {
            turnstile?: {
                reset: () => void;
            };
        }
    }

    const signInForm = document.querySelector("#signin-form") as HTMLFormElement;
    const formSubmitBtn = document.getElementById("sign-in") as HTMLButtonElement;

    signInForm?.addEventListener("submit", async (e) => {
        e.preventDefault();
        formSubmitBtn.disabled = true;
        formSubmitBtn.textContent = "Signing in...";

        try {
            const turnstileToken = (
                document.querySelector(
                    "[name='cf-turnstile-response']"
                ) as HTMLInputElement
            )?.value;

            if (!turnstileToken) {
                throw new Error("verification_missing");
            }

            const formData = new FormData(signInForm);
            formData.append("captchaToken", turnstileToken);

            const results = await actions.signIn(formData);

            if (!results.data?.success) {
                if (results.data?.message?.includes("captcha protection")) {
                    alert("Verification failed. Please try again.");
                    if (window.turnstile) {
                        window.turnstile.reset();
                    }
                    formSubmitBtn.disabled = false;
                    formSubmitBtn.textContent = "Sign In";
                    return;
                } else {
                    alert("Oops! Could not sign in. Please try again");
                    formSubmitBtn.disabled = false;
                    formSubmitBtn.textContent = "Sign In";
                    return;
                }
            }

            formSubmitBtn.textContent = "Sign In";
            alert("Please check your email to sign in");
        } catch (error) {
            if (window.turnstile) {
                window.turnstile.reset();
            }
            formSubmitBtn.disabled = false;
            formSubmitBtn.textContent = "Sign In";
            console.log(error);
            alert("Something went wrong. Please try again");
        }
    });
</script>

After waiting for the token and handling potential errors, the most important part for this new flow to work is to then pass the token to the astro action that handles the sign-in. This allows Supabase to perform token verification on our behalf, eliminating the need to call Cloudflare’s verify APIs directly.
We do that in this part of the code:

const formData = new FormData(signInForm);
            formData.append("captchaToken", turnstileToken);

            const results = await actions.signIn(formData);

Adjusting the Auth Astro Action

Now, we need to adjust the emailSignUp action in the index.ts file in the actions folder. The action now needs to accept the Turnstile token and pass it back to the Supabase method.

Replace the signIn function definition with this:

    signIn: defineAction({
        accept: "form",
        input: z.object({
            email: z.string().email(),
            captchaToken: z.string(),
        }),
        handler: async (input, context) => {
            return emailSignUp(input, context);
        },
    }),

The only change here is adding the captchaToken to the expected input object. Then adjust the emailSignUp action to include the captcha. In the end, it should look like this:

const emailSignUp = async (
    {
        email,
        captchaToken,
    }: {
        email: string;
        captchaToken: string;
    },
    context: ActionAPIContext
) => {
    console.log("Sign up action");
    try {
        const supabase = createClient({
            request: context.request,
            cookies: context.cookies,
        });

        console.log("Request cookies:", context.request.headers.get("Cookie"));

        const { data, error } = await supabase.auth.signInWithOtp({
            email,
            options: {
                captchaToken,
                emailRedirectTo: "http://localhost:4321/api/exchange",
            },
        });

        if (error) {
            console.error("Sign up error", error);
            return {
                success: false,
                message: error.message,
            };
        } else {
            console.log("Sign up success", data);
            return {
                success: true,
                message: "Successfully logged in",
            };
        }
    } catch (err) {
        console.error("SignUp action other error", err);
        return {
            success: false,
            message: "Unexpected error",
        };
    }
};

Testing the Turnstile

All that is left is to verify that everything still works as expected and that the Turnstile works. Open an integrated terminal and run: npm run dev, then open the provided localhost URL.

You should see the same screen as before, but with the Turnstile addition:

Sign in page with a field for email input. A success message with a checkmark is displayed below, followed by a "Sign In" button.

You should see that all works by attempting to sign in. You should receive an email and be correctly redirected to the protected page.

Text reads: "You are logged in!" with a field labeled "Your user Id" and a "Sign Out" button below.

With that, you have successfully protected your auth form in an Astro project using Supabase auth and Cloudflare’s Turnstile.

Notes and Resources

Here are some useful resources on Cloudflare’s Turnstile, Astro actions, and Supabase auth:

10
Subscribe to my newsletter

Read articles from Fatuma Abdullahi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Fatuma Abdullahi
Fatuma Abdullahi

I'm a software engineer, a go-getter, a writer and tiny youTuber. I like teaching what I learn and encouraging others in this journey.