Close Menu
    DevStackTipsDevStackTips
    • Home
    • News & Updates
      1. Tech & Work
      2. View All

      This week in AI dev tools: Gemini 2.5 Pro and Flash GA, GitHub Copilot Spaces, and more (June 20, 2025)

      June 20, 2025

      Gemini 2.5 Pro and Flash are generally available and Gemini 2.5 Flash-Lite preview is announced

      June 19, 2025

      CSS Cascade Layers Vs. BEM Vs. Utility Classes: Specificity Control

      June 19, 2025

      IBM launches new integration to help unify AI security and governance

      June 18, 2025

      Monster Hunter Wilds game reviews hit “Overwhelmingly Negative” on Steam — can Capcom turn it around?

      June 20, 2025

      I played Marvel Cosmic Invasion — pure, simple co-op fun that shouldn’t be missed

      June 20, 2025

      Microsoft readies new Windows 11 feature drop for next month — here’s what’s coming, and when

      June 20, 2025

      I finally built my first keyboard from scratch — thanks to Razer actually offering everything you need for the first time

      June 20, 2025
    • Development
      1. Algorithms & Data Structures
      2. Artificial Intelligence
      3. Back-End Development
      4. Databases
      5. Front-End Development
      6. Libraries & Frameworks
      7. Machine Learning
      8. Security
      9. Software Engineering
      10. Tools & IDEs
      11. Web Design
      12. Web Development
      13. Web Security
      14. Programming Languages
        • PHP
        • JavaScript
      Featured

      Dr. Axel’s JavaScript flashcards

      June 20, 2025
      Recent

      Dr. Axel’s JavaScript flashcards

      June 20, 2025

      Syntax-Highlight – Custom Element For Syntax Highlighting Content

      June 20, 2025

      WelsonJS – Build a Windows app on the Windows built-in JavaScript engine

      June 20, 2025
    • Operating Systems
      1. Windows
      2. Linux
      3. macOS
      Featured

      Monster Hunter Wilds game reviews hit “Overwhelmingly Negative” on Steam — can Capcom turn it around?

      June 20, 2025
      Recent

      Monster Hunter Wilds game reviews hit “Overwhelmingly Negative” on Steam — can Capcom turn it around?

      June 20, 2025

      I played Marvel Cosmic Invasion — pure, simple co-op fun that shouldn’t be missed

      June 20, 2025

      Microsoft readies new Windows 11 feature drop for next month — here’s what’s coming, and when

      June 20, 2025
    • Learning Resources
      • Books
      • Cheatsheets
      • Tutorials & Guides
    Home»Development»How to Build Secure SSR Authentication with Supabase, Astro, and Cloudflare Turnstile

    How to Build Secure SSR Authentication with Supabase, Astro, and Cloudflare Turnstile

    June 20, 2025

    In this guide, you’ll build a full server-side rendered (SSR) authentication system using Astro, Supabase, and Cloudflare Turnstile to protect against bots.

    By the end, you’ll have a fully functional authentication system with Astro actions, magic link authentication using Supabase, bot protection via Cloudflare Turnstile, protected routes and middleware, and secure session management.

    Table of Contents

    • Prerequisites

    • Understanding the Technologies

      • What is Astro?

      • What are Astro Actions?

      • What is Supabase?

      • What is Cloudflare Turnstile?

    • Understanding SSR Authentication

      • SSR vs. SPA Authentication
    • Why Protect Auth Forms?

    • Part 1: How to Set Up the Backend

      • Set Up Supabase Backend

      • Set Up Cloudflare Turnstile

    • Part 2: How to Set Up the Frontend

      • Create the Astro Project

      • Configure Astro for SSR

      • Install Supabase Dependencies

      • Configure Environment Variables

    • Part 3: How to Set Up Supabase SSR

      • Create the Supabase Client

      • Create Middleware for Route Protection

    • Part 4: How to Build the User Interface

      • Update the Layout

      • Create the Sign-In Page

      • Create the Protected Page

    • Part 5: How to Set Up Astro Actions

      • Create the Authentication Actions

      • Create the Code Exchange API Route

    • Part 6: How to Test Your Application

    • Notes and Additional Resources

      • Useful Documentation

      • Complete Code Repository

    Prerequisites

    This tutorial assumes you are familiar with:

    • Web development frameworks

    • Basic authentication flows

    • Basic Backend-as-a-Service (BaaS) concepts

    Understanding the Technologies

    What is Astro?

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

    What are Astro Actions?

    Astro actions allow you to write server-side functions that can be called without explicitly setting up API routes. They provide many useful utilities that simplify the process of running server logic and can be called from both client and server environments.

    What is Supabase?

    Supabase is an open-source Backend-as-a-Service that builds upon Postgres. It provides key features such as authentication, real-time capabilities, edge functions, storage, and more. Supabase offers both a hosted version for easy scaling and a self-hostable version for full control.

    What is Cloudflare Turnstile?

    Turnstile is Cloudflare’s replacement for CAPTCHAs, which are visual puzzles used to differentiate between genuine users and bots. Unlike traditional CAPTCHAs, which are visually clunky, annoying, and sometimes difficult to solve, Turnstile detects malicious activity without requiring users to solve puzzles, while providing a better user experience.

    Understanding SSR Authentication

    Server-side rendered (SSR) auth refers to handling authentication on the server using a cookie-based authentication method.

    The flow works as follows:

    1. The server creates a session and stores a session ID in a cookie sent to the client

    2. The browser receives the cookie and automatically includes it in future requests

    3. The server uses the cookie to determine if the user is authenticated

    Since browsers cannot modify HTTP-only cookies and servers cannot access local storage, SSR authentication requires careful management to prevent security risks such as session hijacking and stale sessions.

    SSR vs. SPA Authentication

    Single-Page Applications (SPAs), like traditional React apps, handle authentication on the client side because they don’t have direct access to a server. SPAs typically use JWTs stored in local storage, cookies, or session storage, sending these tokens in HTTP headers when communicating with servers.

    Why Protect Auth Forms?

    Authentication protects sensitive resources from unauthorized access, making auth forms primary targets for bots and malicious actors. Taking extra precautions is important for maintaining security.

    Part 1: How to Set Up the Backend

    Set Up Supabase Backend

    First, you’ll need a Supabase account. Create a project, then:

    1. Go to the Authentication tab in the sidebar

    2. Click the Sign In / Up tab under Configuration

    3. Enable user sign-ups

    4. Scroll down to Auth Providers and enable email (disable email confirmation for this tutorial)

    Supabase authentication configuration interface showing user signup options and email provider enabled

    Set Up Cloudflare Turnstile

    1. Log in or register for a Cloudflare account

    2. Click the Turnstile tab in the sidebar

    3. Click the “Add widget” button

    4. Name your widget and add “localhost” as the hostname

    5. Leave all other settings as default, and create the widget

    Cloudflare Turnstile widget creation interface

    After creating the widget, copy the secret key and add it to your Supabase dashboard:

    1. Go back to Supabase Authentication settings

    2. Navigate to the Auth Protection tab under Configuration

    3. Turn on Captcha protection

    4. Choose Cloudflare as the provider

    5. Paste your secret key

    Supabase Attack Protection settings with Turnstile configuration

    Part 2: How to Set Up the Frontend

    Create the Astro Project

    Next, you will need to create an Astro project. Open your preferred IDE or Text editor’s integrated terminal and run the following command to scaffold an Astro project in a folder named “ssr-auth.” Feel free to use any name you like.

    npm create astro@latest ssr-auth
    

    Follow the provided prompts and choose a basic template to start with. When it’s done, change into the folder, then run npm install to install dependencies, followed by npm run dev to start the server, and your site will be available at localhost:4321.

    Configure Astro for SSR

    Set Astro to run in SSR mode by adding output: "server", to the defineConfig function found in the astro.config.mjs file at the root of the folder.

    Next, add an adapter to create a server runtime. For this, use the Node.js adapter by running this command in a terminal: npx astro add node. This will add it and automatically make all relevant changes.

    Finally, add Tailwind for styling. Run this command in a terminal window: npx astro add tailwind. Follow the prompts, and it will make any changes necessary.

    At this stage, your astro.config.mjs should look like this:

    // @ts-check
    import { defineConfig } from "astro/config";
    import node from "@astrojs/node";
    import tailwindcss from "@tailwindcss/vite";
    
    // https://astro.build/config
    export default defineConfig({
      output: "server",
      adapter: node({
        mode: "standalone",
      }),
      vite: {
        plugins: [tailwindcss()],
      },
    });
    

    Install Supabase Dependencies

    You can do this by running the following command:

    npm install @supabase/supabase-js @supabase/ssr
    

    Configure Environment Variables

    Create a .env file in the project root and add the following. Remember to replace with your actual credentials:

    SUPABASE_URL=<YOUR_URL>
    SUPABASE_ANON_KEY=<YOUR_ANON_KEY>
    TURNSTILE_SITE_KEY=<YOUR_TURNSTILE_SITE_KEY>
    

    You can get the Supabase values from the dashboard:

    Supabase project connection interface showing environment variables

    💡Note: In Astro, environment variables accessed on the client side must be prefixed with ‘PUBLIC’. But since we’re using Astro actions that run on the server, the prefix is not required.

    Part 3: How to Set Up Supabase SSR

    Create the Supabase Client

    Create src/lib/supabase.ts:

    
    import { createServerClient, parseCookieHeader } from "@supabase/ssr";
    import type { AstroCookies } from "astro";
    
    export function createClient({
        request,
        cookies,
    }: {
        request: Request;
        cookies: AstroCookies;
    }) {
        const cookieHeader = request.headers.get("Cookie") || "";
    
        return createServerClient(
            import.meta.env.SUPABASE_URL,
            import.meta.env.SUPABASE_ANON_KEY,
            {
                cookies: {
                    getAll() {
                        const cookies = parseCookieHeader(cookieHeader);
                        return cookies.map(({ name, value }) => ({
                            name,
                            value: value ?? "",
                        }));
                    },
                    setAll(cookiesToSet) {
                        cookiesToSet.forEach(({ name, value, options }) =>
                            cookies.set(name, value, options)
                        );
                    },
                },
            }
        );
    }
    

    This sets up Supabase to handle cookies in a server-rendered application and exports a function that takes the request and cookies object as input. The function is set up like this because Astro has three ways to access request and cookie information:

    • Through Astro’s global object, which is only available on Astro pages.

    • Through AstroAPIContext object, which is only available in Astro actions.

    • Through APIContext which is a subset of the global object and is available through API routes and middleware.

    So the createClient function accepts the request and cookies objects separately to make it flexible and applicable in the various contexts in which it may be used.

    Create Middleware for Route Protection

    Next, create a middleware.ts file in the src folder and paste this into it:

    import { defineMiddleware } from "astro:middleware";
    import { createClient } from "./lib/supabase";
    
    export const onRequest = defineMiddleware(async (context, next) => {
        const { pathname } = context.url;
    
        console.log("Middleware executing for path:", pathname);
    
        const supabase = createClient({
            request: context.request,
            cookies: context.cookies,
        });
    
        if (pathname === "/protected") {
            console.log("Checking auth for protected route");
    
            const { data } = await supabase.auth.getUser();
    
            // If no user, redirect to index
            if (!data.user) {
                return context.redirect("/");
            }
        }
    
        return next();
    });
    

    This middleware checks for an active user when accessing the protected route and redirects unauthenticated users to the index page.

    Part 4: How to Build the User Interface

    Update the Layout

    First, update src/layouts/Layout.astro to include the Turnstile script. Add this just above the closing </head> tag:

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

    Create the Sign-In Page

    Replace the contents of src/pages/index.astro:

    ---
    import Layout from "../layouts/Layout.astro";
    import { createClient } from "../lib/supabase";
    import "../styles/global.css";
    
    const supabase = createClient({
        request: Astro.request,
        cookies: Astro.cookies,
    });
    
    const { data } = await supabase.auth.getUser();
    
    if (data.user) {
        return Astro.redirect("/protected");
    }
    
    const apiKey = import.meta.env.TURNSTILE_SITE_KEY;
    ---
    
    <Layout>
        <section class="flex flex-col items-center justify-center m-30">
            <h1 class="text-4xl text-left font-bold mb-12">Sign In to Your Account</h1>
            <form id="signin-form" class="flex flex-col gap-2 w-1/2">
                <label for="email" class="">Enter your email</label>
                <input
                    type="email"
                    name="email"
                    id="email"
                    placeholder="youremail@example.com"
                    class="border border-gray-500 rounded-md p-2"
                    required
                />
                <div class="cf-turnstile" data-sitekey={apiKey}></div>
                <button
                    type="submit"
                    id="sign-in"
                    class="bg-gray-600 hover:bg-gray-700 p-2 rounded-md text-white font-bold w-full cursor-pointer disabled:bg-gray-500 disabled:hover:bg-gray-500 disabled:cursor-not-allowed"
                    >Sign In</button
                >
            </form>
        </section>
    </Layout>
    

    Here, the frontmatter creates a Supabase server client and then uses it to check if we have an active user. It redirects based on this information. This works because the front matter runs on the server side, and the project is set to server output.

    The template displays a simple form with an email input. To complete it, add this below the closing </Layout> tag:

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

    This adds some vanilla JavaScript that calls the SignIn Upon form submission. This action provides user feedback through alerts and manages the button’s text and disabled state. This effectively adds client-side interactivity to the page.

    Create the Protected Page

    Create src/pages/protected.astro:

    ---
    import Layout from "../layouts/Layout.astro";
    import { createClient } from "../lib/supabase";
    import "../styles/global.css";
    
    const supabase = createClient({
        request: Astro.request,
        cookies: Astro.cookies,
    });
    
    const { data } = await supabase.auth.getUser();
    ---
    
    <Layout>
        <section class="flex flex-col items-center justify-center m-30">
            <h1 class="text-4xl text-left font-bold mb-12">You are logged in!</h1>
            <p class="mb-6">Your user Id: {data.user?.id}</p>
            <button
                id="sign-out"
                class="bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-md text-white font-bold cursor-pointer disabled:bg-gray-500 disabled:hover:bg-gray-500 disabled:cursor-not-allowed"
                >Sign Out</button
            >
        </section>
    </Layout>
    
    <script>
        import { actions } from "astro:actions";
        const signOutBtn = document.getElementById("sign-out") as HTMLButtonElement;
    
        signOutBtn?.addEventListener("click", async (e) => {
            e.preventDefault();
            signOutBtn!.disabled = true;
            signOutBtn!.textContent = "Signing out...";
    
            try {
                const results = await actions.signOut();
    
                if (!results.data?.success) {
                    signOutBtn!.disabled = false;
                    signOutBtn!.textContent = "Sign Out";
                    return alert("Oops! Could not sign Out. Please try again");
                }
                return window.location.reload();
            } catch (error) {
                signOutBtn.disabled = false;
                signOutBtn.textContent = "Sign Out";
                console.log(error);
                return alert("Something went wrong. Please try again");
            }
        });
    </script>
    

    This page retrieves the user data server-side in the front matter and displays it in the template, along with a sign-out button.

    The JavaScript in the script tags handle calling the sign-out action, user feedback, and button state, as in the index.astro page.

    Part 5: How to Set Up Astro Actions

    Create the Authentication Actions

    Finally, add an actions folder in the src folder and create an index.ts file to hold our logic. Paste the following into it:

    import { defineAction, type ActionAPIContext } from "astro:actions";
    import { z } from "astro:schema";
    import { createClient } from "../lib/supabase";
    
    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,
            });
    
            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",
            };
        }
    };
    
    export const server = {
        signIn: defineAction({
            accept: "form",
            input: z.object({
                email: z.string().email(),
                captchaToken: z.string(),
            }),
            handler: async (input, context) => {
                return emailSignUp(input, context);
            },
        }),
        signOut: defineAction({
            handler: async (_, context) => {
                const supabase = createClient({
                    request: context.request,
                    cookies: context.cookies,
                });
                const { error } = await supabase.auth.signOut();
                if (error) {
                    console.error("Sign out error", error);
                    return {
                        success: false,
                        message: error.message,
                    };
                }
                return {
                    success: true,
                    message: "Successfully signed out",
                };
            },
        }),
    };
    

    This action handles both sign-in and sign-out methods. A Supabase server instance is created during the sign-in method, and the magic link method is used for sign-in. It passes a redirect URL, which we have yet to create, and handles any errors that may occur.

    It also passes the token verification, allowing Supabase to perform verification on our behalf, eliminating the need to call Cloudflare’s verify APIs directly.

    The sign-out method calls Supabase’s sign-out method and handles any potential errors.

    The redirect URL refers to an API route that exchanges the code from the email Supabase sends for a session that Supabase handles.

    Create the Code Exchange API Route

    Create src/pages/api/exchange.ts:

    import type { APIRoute } from "astro";
    import { createClient } from "../../lib/supabase";
    
    export const GET: APIRoute = async ({ request, cookies, redirect }) => {
        const url = new URL(request.url);
        const code = url.searchParams.get("code");
    
        if (!code) {
            return redirect("/");
        }
    
        const supabase = createClient({ request, cookies });
        const { error } = await supabase.auth.exchangeCodeForSession(code);
    
        if (error) {
            console.error("Error exchanging code for session:", error);
            return redirect("/404");
        }
    
        return redirect("/protected");
    };
    

    This grabs the code from the URL in the magic link sent, creates a server client, and calls the exchangeCodeForSession method with the code. It handles any error by redirecting to Astro’s built-in not-found page.

    Otherwise, it will redirect to the protected page as Supabase handles the session implementation details.

    Part 6: How to Test Your Application

    Start your development server: npm run dev

    Visit the provided localhost URL. You should see the sign-in page with the Turnstile widget:

    Sign-in page with Turnstile verification and email input field

    If you try to access the /protected page, it will redirect you back to this view until you sign in. Now, sign in, and you should get an email with a link that will redirect you to the /protected page. This is what you should see:

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

    And with that, you’ve successfully built a comprehensive auth system that leverages Astro actions, Supabase auth, and Cloudflare Turnstile’s bot protection. This setup provides a secure, user-friendly authentication experience while protecting your application from malicious actors.

    Notes and Additional Resources

    Useful Documentation

    • Supabase’s advanced guide to SSR

    • Supabase SSR package

    • Astro Cookies documentation

    • Supabase PKCE flow documentation

    • Astro Actions documentation

    • Get started with Turnstile

    Complete Code Repository

    The complete code for this project is available on GitHub:

    • Base authentication setup

    • With Cloudflare Turnstile

    Source: freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More 

    Facebook Twitter Reddit Email Copy Link
    Previous ArticleHow to Start a Career in Technical Writing by Contributing to Open Source
    Next Article How to Assign Dataverse Security Roles at Scale

    Related Posts

    Development

    How to Assign Dataverse Security Roles at Scale

    June 20, 2025
    Development

    How to Start a Career in Technical Writing by Contributing to Open Source

    June 20, 2025
    Leave A Reply Cancel Reply

    For security, use of Google's reCAPTCHA service is required which is subject to the Google Privacy Policy and Terms of Use.

    Continue Reading

    FREAK attack: security vulnerability breaks HTTPS protection

    Development

    CISA Adds Erlang SSH and Roundcube Flaws to Known Exploited Vulnerabilities Catalog

    Development

    GitLab Duo Vulnerability Enabled Attackers to Hijack AI Responses with Hidden Prompts

    Development

    Will you be the boss of your own AI workforce?

    Artificial Intelligence

    Highlights

    CVE-2025-44115 – Cotonti Siena Cross-Site Scripting Vulnerability

    June 2, 2025

    CVE ID : CVE-2025-44115

    Published : June 2, 2025, 4:15 p.m. | 3 hours, 9 minutes ago

    Description : A vulnerability has been found in Cotonti Siena v0.9.25. Affected by this vulnerability is the file /admin.php?m=config&n=edit&o=core&p=title. The manipulation of the value of title leads to cross-site scripting.

    Severity: 0.0 | NA

    Visit the link for more details, such as CVSS details, affected products, timeline, and more…

    CVE-2025-48995 – SignXML Timing Attack HMAC Leak

    June 2, 2025

    CVE-2025-26842 – Znuny S/MIME Encryption Information Disclosure Vulnerability

    May 8, 2025

    Generate a Detailed Application Report with Laravel Decomposer

    May 23, 2025
    © DevStackTips 2025. All rights reserved.
    • Contact
    • Privacy Policy

    Type above and press Enter to search. Press Esc to cancel.