DEV Community

Carlo Gino Catapang
Carlo Gino Catapang

Posted on • Updated on • Originally published at l.carlogino.com

Remix and Supabase Authentication

How to secure a Remix and Supabase application using Row Level Security

NOTE: This tutorial is deprecated in favor of the official documentation.

Table of Contents

TL;DR: Source and Demo

Here's a live demo

Link to the source code

Link to step by step commits

Introduction

This blog will focus on securing our Remix application with Supabase's Row Level Security (RLS) feature.
If you want to know the context of what application I'm talking about, you can refer to my another blog.

Setting up Supabase

Instead of updating my database from the previous blog, I'm just going to re-create it.

Create a table to contain user_id

CREATE TABLE words (
  id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name varchar NOT NULL,
  definitions varchar ARRAY NOT NULL,
  sentences varchar ARRAY NOT NULL,
  type varchar NOT NULL,
  user_id uuid NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Add a foreign key in user_id pointing to auth.users

alter table words
add constraint words_users_fk
foreign key (user_id)
references auth.users (id);
Enter fullscreen mode Exit fullscreen mode

Create Row Level Security Supabase Policies

CREATE POLICY "anon_select" ON public.words FOR SELECT USING (
  auth.role() = 'anon' or auth.role() = 'authenticated'
);

CREATE POLICY "auth_insert" ON public.words FOR INSERT WITH CHECK (
  auth.role() = 'authenticated'
);

CREATE POLICY "user_based__update" ON public.words FOR UPDATE USING (
  auth.uid() = user_id
);

CREATE POLICY "user_based_delete" ON public.words FOR DELETE USING (
  auth.uid() = user_id
);
Enter fullscreen mode Exit fullscreen mode

Implement server-side utilities to manage Supabase session

Create server instance of Supabase client

// app/utils/supabase.server.ts
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.SUPABASE_URL as string;
const supabaseKey = process.env.SUPABASE_ANON_KEY as string;

export const supabase = createClient(supabaseUrl, supabaseKey);
Enter fullscreen mode Exit fullscreen mode

Use createCookieSessionStorage to help in managing our Supabase token

// app/utils/supabase.server.ts
// ...
import { createCookieSessionStorage } from "remix";

// ...

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    // a Cookie from `createCookie` or the CookieOptions to create one
    cookie: {
      name: "supabase-session",

      // all of these are optional
      expires: new Date(Date.now() + 3600),
      httpOnly: true,
      maxAge: 60,
      path: "/",
      sameSite: "lax",
      secrets: ["s3cret1"],
      secure: true,
    },
  });

export { getSession, commitSession, destroySession };
Enter fullscreen mode Exit fullscreen mode

Create a utility to set the Supabase token from the Request

// app/utils/supabase.server.ts
// ...

export const setAuthToken = async (request: Request) => {
  let session = await getSession(request.headers.get("Cookie"));

  supabase.auth.setAuth(session.get("access_token"));

  return session;
};
Enter fullscreen mode Exit fullscreen mode

Setting up authentication in the Remix side

Create client-side utilities for managing Supabase session

Create Supabase Provider and a custom hook which returns the Supabase instance

// app/utils/supabase-client.tsx
import { SupabaseClient } from "@supabase/supabase-js";
import React from "react";

export const SupabaseContext = React.createContext<SupabaseClient>(
  null as unknown as SupabaseClient
);

export const SupabaseProvider: React.FC<{ supabase: SupabaseClient }> = ({
  children,
  supabase,
}) => (
  <SupabaseContext.Provider value={supabase}>
    {children}
  </SupabaseContext.Provider>
);

export const useSupabase = () => React.useContext(SupabaseContext);
Enter fullscreen mode Exit fullscreen mode

Pass Supabase environment variables to our client

// app/root.tsx
export const loader = () => {
  return {
    supabaseKey: process.env.SUPABASE_ANON_KEY,
    supabaseUrl: process.env.SUPABASE_URL,
  };
};
Enter fullscreen mode Exit fullscreen mode

Create a Supabase instance and pass it into the root level Supabase provider

// app/root.tsx
import { createClient } from "@supabase/supabase-js";
import { SupabaseProvider } from "./utils/supabase-client";

// export const loader...

export default function App() {
  const loader = useLoaderData();

  const supabase = createClient(loader.supabaseUrl, loader.supabaseKey);

  return (
    <Document>
      <SupabaseProvider supabase={supabase}>
        <Layout>
          <Outlet />
        </Layout>
      </SupabaseProvider>
    </Document>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create the /auth route

Since I'm too lazy to implement a login page, I'll just use the UI provided by Supabase.

Install @supabase/ui

npm install @supabase/ui

yarn add @supabase/ui
Enter fullscreen mode Exit fullscreen mode

Create the main auth component

You can create your custom sign-up and sign-in form if you want.

// app/routes/auth.tsx
import React from "react";
import { Auth } from "@supabase/ui";
import { useSupabase } from "~/utils/supabase-client";

export default function AuthBasic() {
  const supabase = useSupabase();

  return (
    <Auth.UserContextProvider supabaseClient={supabase}>
      <Container> {/* TODO */}
        <Auth supabaseClient={supabase} />
      </Container>
    </Auth.UserContextProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create the component to inform the server that we have a Supabase session

// app/routes/auth.tsx
import React, { useEffect } from "react";
import { useSubmit } from "remix";

const Container: React.FC = ({ children }) => {
  const { user, session } = Auth.useUser();
  const submit = useSubmit();

  useEffect(() => {
    if (user) {
      const formData = new FormData();

      const accessToken = session?.access_token;

      // you can choose whatever conditions you want
      // as long as it checks if the user is signed in
      if (accessToken) {
        formData.append("access_token", accessToken);
        submit(formData, { method: "post", action: "/auth" });
      }
    }
  }, [user]);

  return <>{children}</>;
};

// ...
Enter fullscreen mode Exit fullscreen mode

Create an action handler to process the Supabase token

// app/routes/auth.tsx
import { Auth } from "@supabase/ui";
import { useSubmit, redirect } from "remix";
import type { ActionFunction } from "remix";
import React from "react";
import { useSupabase } from "~/utils/supabase-client";
import { commitSession, getSession } from "~/utils/supabase.server";

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();

  const session = await getSession(request.headers.get("Cookie"));

  session.set("access_token", formData.get("access_token"));

  return redirect("/words", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
};

// ...
Enter fullscreen mode Exit fullscreen mode

After logging in, the user will be redirected to the /words route.

If you want to test without signing up, use the following credentials:

email: dev.codegino@gmail.com

password: testing

Signing out

Create a logout button in the header

// app/root.tsx
import { {/*...*/}, useSubmit } from "remix";
import { {/*...*/}, useSupabase } from "./utils/supabase-client";
import { Button } from "./components/basic/button";

function Layout({ children }: React.PropsWithChildren<{}>) {
  const submit = useSubmit();
  const supabase = useSupabase();

  const handleSignOut = () => {
    supabase.auth.signOut().then(() => {
      submit(null, { method: "post", action: "/signout" });
    });
  };

  return (
    <main>
      <header>
        {supabase.auth.session() && (
          <Button type="button" onClick={handleSignOut}>
            Sign out
          </Button>
        )}
      </header>
      {children}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create an action handler

I don't want to pollute my other route, so I will create my signout action handler separately

// app/routes/signout.tsx
import { destroySession, getSession } from "../utils/supabase.server";
import { redirect } from "remix";
import type { ActionFunction } from "remix";

export const action: ActionFunction = async ({ request }) => {
  let session = await getSession(request.headers.get("Cookie"));

  return redirect("/auth", {
    headers: {
      "Set-Cookie": await destroySession(session),
    },
  });
};

export const loader = () => {
  // Redirect to `/` if user tried to access `/signout`
  return redirect("/");
};
Enter fullscreen mode Exit fullscreen mode

TL;DR version of using our setup

Using in a loader or action

export const action = async ({ request, params }) => {
  // Just set the token to any part you want to have access to.
  // I haven't tried making a global handler for this,
  // but I prefer to be explicit about setting this.
  await setAuthToken(request);

  await supabase.from("words").update(/*...*/);
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Conditional rendering based on auth state

export default function Index() {
  const supabase = useSupabase();

  return supabase.auth.user()
    ? <div>Hello world</div>
    : <div>Please sign in</div>;
}
Enter fullscreen mode Exit fullscreen mode

NOTE: Conditional server-side rendering might cause hydration warning,

I'll fix this in another blog post.

Using in CRUD Operations

The examples below are a longer version of using our setup for CRUD operations.

Fetching All operation

// app/routes/words
import { Form, useTransition } from "remix";
import type { LoaderFunction } from "remix";
import { useLoaderData, Link, Outlet } from "remix";
import { Button } from "~/components/basic/button";
import { supabase } from "~/utils/supabase.server";
import type { Word } from "~/models/word";
import { useSupabase } from "~/utils/supabase-client";

export const loader: LoaderFunction = async () => {
  // No need to add auth here, because GET /words is public
  const { data: words } = await supabase
    .from<Word>("words")
    .select("id,name,type");

  // We can pick and choose what we want to display
  // This can solve the issue of over-fetching or under-fetching
  return words;
};

export default function Index() {
  const words = useLoaderData<Word[]>();
  const transition = useTransition();
  const supabase = useSupabase();

  return (
    <main className="p-2">
      <h1 className="text-3xl text-center mb-3">English words I learned</h1>
      <div className="text-center mb-2">Route State: {transition.state}</div>
      <div className="grid grid-cols-1 md:grid-cols-2 ">
        <div className="flex flex-col items-center">
          <h2 className="text-2xl pb-2">Words</h2>
          <ul>
            {words.map((word) => (
              <li key={word.id}>
                <Link to={`/words/${word.id}`}>
                  {word.name} | {word.type}
                </Link>
              </li>
            ))}
          </ul>
          {/* Adding conditional rendering might cause a warning,
          We'll deal with it later */}
          {supabase.auth.user() ? (
            <Form method="get" action={"/words/add"} className="pt-2">
              <Button
                type="submit"
                className="hover:bg-primary-100 dark:hover:bg-primary-900"
              >
                Add new word
              </Button>
            </Form>
          ) : (
            <Form method="get" action={`/auth`} className="flex">
              <Button type="submit" color="primary" className="w-full">
                Sign-in to make changes
              </Button>
            </Form>
          )}
        </div>
        <Outlet />
      </div>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Retrieve one and Delete one operation

// app/routes/words/$id
import { Form, useLoaderData, redirect, useTransition } from "remix";
import type { LoaderFunction, ActionFunction } from "remix";
import type { Word } from "~/models/word";
import { Input } from "~/components/basic/input";
import { Button } from "~/components/basic/button";
import { setAuthToken, supabase } from "~/utils/supabase.server";
import { useSupabase } from "~/utils/supabase-client";

// Here's how to delete one entry
export const action: ActionFunction = async ({ request, params }) => {
  const formData = await request.formData();

  // Auth Related Code
  await setAuthToken(request);

  if (formData.get("_method") === "delete") {
    await supabase
      .from<Word>("words")
      .delete()
      .eq("id", params.id as string);

    return redirect("/words");
  }
};

// Here's the how to fetch one entry
export const loader: LoaderFunction = async ({ params }) => {
  // No need to add auth here, because GET /words is public
  const { data } = await supabase
    .from<Word>("words")
    .select("*")
    .eq("id", params.id as string)
    .single();

  return data;
};

export default function Word() {
  const word = useLoaderData<Word>();
  const supabase = useSupabase();
  let transition = useTransition();

  return (
    <div>
      <h3>
        {word.name} | {word.type}
      </h3>
      <div>Form State: {transition.state}</div>
      {word.definitions.map((definition, i) => (
        <p key={i}>
          <i>{definition}</i>
        </p>
      ))}
      {word.sentences.map((sentence, i) => (
        <p key={i}>{sentence}</p>
      ))}

      {/* Adding conditional rendering might cause a warning,
      We'll deal with it later */}
      {supabase.auth.user() && (
        <>
          <Form method="post">
            <Input type="hidden" name="_method" value="delete" />
            <Button type="submit" className="w-full">
              Delete
            </Button>
          </Form>
          <Form method="get" action={`/words/edit/${word.id}`} className="flex">
            <Button type="submit" color="primary" className="w-full">
              Edit
            </Button>
          </Form>
        </>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create operation

// app/routes/words/add
import { redirect } from "remix";
import type { ActionFunction } from "remix";
import { setAuthToken, supabase } from "~/utils/supabase.server";
import { WordForm } from "~/components/word-form";

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();

  // Auth Related Code
  const session = await setAuthToken(request);

  const newWord = {
    name: formData.get("name"),
    type: formData.get("type"),
    sentences: formData.getAll("sentence"),
    definitions: formData.getAll("definition"),
    user_id: session.get("uuid"),
  };

  const { data, error } = await supabase
    .from("words")
    .insert([newWord])
    .single();

  if (error) {
    return redirect(`/words`);
  }

  return redirect(`/words/${data?.id}`);
};

export default function AddWord() {
  return <WordForm />;
}
Enter fullscreen mode Exit fullscreen mode

Update operation

// app/routes/words/edit/$id
import { useLoaderData, redirect } from "remix";
import type { LoaderFunction, ActionFunction } from "remix";
import { WordForm } from "~/components/word-form";
import type { Word } from "~/models/word";
import { setAuthToken, supabase } from "~/utils/supabase.server";

export const action: ActionFunction = async ({ request, params }) => {
  const formData = await request.formData();
  const id = params.id as string;

  const updates = {
    type: formData.get("type"),
    sentences: formData.getAll("sentence"),
    definitions: formData.getAll("definition"),
  };

  // Auth Related Code
  await setAuthToken(request);

  await supabase.from("words").update(updates).eq("id", id);

  return redirect(`/words/${id}`);
};

export const loader: LoaderFunction = async ({ params }) => {
  const { data } = await supabase
    .from<Word>("words")
    .select("*")
    .eq("id", params.id as string)
    .single();

  return data;
};

export default function EditWord() {
  const data = useLoaderData<Word>();

  return <WordForm word={data} />;
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

We can still use Supabase only on the client-side as we use it on a typical React application. However, putting the data fetching on the server-side will allow us to benefit from a typical SSR application.

Latest comments (4)

Collapse
 
mrieger profile image
Michael Rieger

Thank you for the tutorial, really helped me a lot!
One thing that I think would be mentionable, even if it is stated in the Supabase docs, is that passing the Supabase anon key to the client is only safe when RLS is enabled and should not be done with any other API keys or tokens that are not safe on the client.

Collapse
 
codegino profile image
Carlo Gino Catapang

Glad that it is helpful. Thanks as well for sharing your thoughts! Cheers.

Collapse
 
andrioid profile image
Andri • Edited

I came to the same conclusion. Doing data from the loaders/actions also embraces the Remix way of doing things. I can't think of a good reason to use the Supabase SDK directly on the client when Remix makes it so easy to hide it behind the server.

Collapse
 
codegino profile image
Carlo Gino Catapang

Thanks for sharing your thoughts. Yeah, I agree with you. Putting Supabase's database calls kind of defeat the purpose of using Remix.