Practical Eight
| Deliverable | Due Date |
|---|---|
| Practical 8 | November 13, 2025 at 11:59 PM |
| Practical 8 (revision) | December 05, 2025 at 11:59 PM |
Goals
- Learn how to add password authentication to an application with Supabase
- Learn how to protect your database with Row Level Security
- Learn about React Context providers and
useContext
Background
In this practical we are going to create a simple message board application. All users will be able to read the board, but only authenticated users will be able to post new content.
Prerequisites
Create the git repository for your practical by accepting the assignment from GitHub Classroom. This will create a new repository for you with the Next/React infrastructure in place.
Clone the repository to you computer with
git clone(get the name of the repository from GitHub).Open up the
package.jsonfile and add your name as the author of the package.Install the module dependencies by typing
pnpm installin the shell in the terminal in the root directory of your package (the directory that contains the package.json file).Get some practice with our new workflow and start by making a feature branch in your repository.
Make sure that you have completed the Getting Started steps and have Docker Desktop installed.
Setting up Authentication
One of Supabase’s features is an authentication manager (Auth). Authentication is a way for users to prove that they are who they say they are and to give your application a way to uniquely identify them. This is separate from Authorization, which is controlling which resources users can interact with. Auth integrates tightly with Supabase’s database, so we can support authorization as well. We are not going to go very deeply into the weeds on this, all we really need to know i=s that we can set up a service to authenticate our users, and then when we make calls to the database using the Supabase Api, the requests are sent with the user’s Auth token, allowing us to handle Row Level Security (RLS) at the database.
Selecting an authentication strategy and provider
Supabase Auth supports most of the primary authentication strategies. You can, if you wish, set up conventional passwords backed by your database. They have support for the “passwordless” strategies like “magic links” (the server emails the user an email with a short lived link inside) and one-time passwords (the server emails the user a code that can be used once to log in).
A very popular option these days is to use an OAuth provider like Google, Apple, GitHub, etc… (there are a lot of options). This is a popular approach because
OAuth is designed to allow applications to request tokens on behalf of resource owners (users) so they can access those resources. Imagine you had written an application that made use of users’ Google data (like their calendar, for example). Users shouldn’t trust your app with their account passwords, but if they want to allow the application to have access to their data, they could log into Google through your application and allow your application to access their Google data, and then Google would give your application a token that could be used to request the account data.
While originally designed to grant applications access to user’s data, it quickly gained traction as a way to perform authentication where we ask the user to log into the service, but we don’t use the token as anything more than proof that user is a valid Apple/Google/GitHub/whatever user.
There are a number of advantages to using one of the third party providers. For users, it means that they don’t have to remember so many passwords. As an application developer we can have more confidence that we are dealing with a real user. As an example, since all members of the Middlebury community have an associated Google account, we could use the Google provider and limit users to those with a middlebury.edu account to create a site that was only open to Middlebury using the same Middlebury password as everything else.
As for the providers? They get to further enmesh their users, and (bonus) know something about their activity when they aren’t on the provider’s site. This can, of course, be seen as a disadvantage for users. I’ve decided not to use services in the past because the only access was through ‘Login with Facebook’…
Since three of the projects are aimed at the Middlebury community, we are going to use Google’s OAuth service so we can limit our users to the Middlebury community.
Setting up Google OAuth
These instructions are based on the Supabase instructions. I attempted to distill them down for you, but feel free to refer to them for more details.
The first thing we need to do is set up the project on the Google side. Start by visiting the Google Cloud Platform dashboard. At the top, you will find a button that opens the project picker. If you have created other projects, it will have a name like mine.

In the picker, Make sure you are in the middlebury.edu organization and then create a new project. You can name it whatever you like. I recommend something like “csci312” (you should be able to use the same project for your group projects).

Next, we want to visit the Google Auth console (conceptually there is a way to get there from the platform dashboard, but honestly I can never reliably find it, so just follow this link). It should say that authorization isn’t configured yet, so click the “Get Started” button. If you don’t have the button, go to the Branding pane from the menu on the left.
- Give the app a name. Set this to “Practical 08” (this is the name of the application that is requesting authentication)
- Set the user support email to you Middlebury email address
- Set the audience to “internal” which will restrict your users to middlebury.edu addresses
- Click Save at the bottom (we can leave the other settings for the time being)
Now select the Data Access pane from the left menu and then click “Add or remove scopes”. This is where we are setting what user information we want our application to request from the user. This will bring up a long list of options. We want ../auth/userinfo.email. This will ask for the user’s email address. We don’t really need anything more for this practical. Click “Update” (or “Save”) at the bottom of the panel, and then click “Save” at the bottom of the Data Access page.

Now you should go to the Clients pane from the left side menu. Click the “Create client”.

The settings should be:
- Application type - Web application
- Name - whatever you like, this is just for internal naming – I called mine Practical 08
- Authorized JavaScript origins - http://127.0.0.1:3000
- Authorized redirect URIs - http://127.0.0.1:54321/auth/v1/callback

Click “Create”. This should give you two values, the client ID and client secret. Copy those somewhere safe for a moment.
The “Authorized JavaScript origins” address is the address of the server that served the page making the request. For the practical we are setting this to our local development server. Notice that this includes the port so if you have multiple servers running and you pop over to a different port number, this won’t work. For your project, you will want to include the address of the server running the deployed version as an additional authorized origin. For full production use, we would remove the local development origin.
When your application makes a request of Google for log in information, it is going to redirect the user to a Google page for the log in (or account selection). When they are done, Google needs to know where to redirect the user to next (and send the user data). This is where the “Authorized redirect URIs” come in.
Look carefully at the port number there – that is the port of the Supabase API server running on your local machine. it has a route for /auth/v1/callback that will collect the user data, store it in the database and then redirect back to our application. Again, you will want to add a different URI for your deployed projects. It will be the same as the URI of your production Supabase project.
The address 127.0.0.1 is a special one called the “loopback address”. It routes back to the same machine that requested it. We will also call this “localhost”. Generally, we can use “localhost” and 127.0.0.1 interchangeably. That is not the case here however. Google expects an exact match.
Configure Supabase to use Google
Now we can finally move to our local code.
Add the client secret to your .env file as `SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_SECRET.
Open supabase/config.toml. Search in the file for [auth.external.apple]. Immediately before that add the following:
[auth.external.google]
enabled = true
client_id = <client id>
secret = "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_SECRET)"
skip_nonce_check = false
Replace <client id> with the client id you got from Google. Save the file and then start the local Supabase instance (or restart it if you already fired it up).
Take a breath – you made it through the configuration.
Getting to know the Messages App
The application we are working on is a very simplistic message board.
index.tsx
I would like you to take a moment to read through the code to understand what is going on. The bulk of the application can be found in src/pages/index.tsx. There is a collection of things going on in there, but in very broad strokes there are only a couple of things to note
- we have a list of messages stored in a state variable called
messages - there is a
useEffectwhich fetches these messages from the server - the messages are mapped into
MapViewcomponents and displayed
That is the bulk of what is going on in here. I do want to point at one new thing that you haven’t seen before. With a message board, we would conceptually like to see messages when they are posted without having to manually reload message from the server to check. Supabase does have some real time facilities to support this behavior. However, in the interest of simplicity for this example, I thought I would show you an old school approach based on polling. The idea is that the client just goes out and re-fetches the message list from time to time.
This is set up in the useEffect. In the useEffect we have the async function that is fetching the messages and updating the messages state. The difference is that I added this line:
// set timer to fetch messages every 5 seconds
const fetchInterval = setInterval(getMessages, 5000);setInterval is like setTimeout, except it runs the function repeatedly until we kill it. So this will fetch new messages every 5 seconds. In truth, we could set this to a pretty long wait since we don’t really have other people posting messages for us…
The other important piece of this is the return value of the useEffect which we have never talked about. If we return a function from a useEffect, the function is run when the component is closed. If we have any kinds of subscriptions then this is the place to shut them down.
return () => {
// clear out the timer if this component is unmounted
clearInterval(fetchInterval);
};The database schema
I have set the database schema up for you already. Reset the database with pnpm db:reset to apply the migration.
We have three tables we will care about: messages, profiles, and users.
The users table is a special one that is set up and maintained by Supabase to handle our users. When someone logs into our application this table will be updated with their information. This table hides in the auth schema. Open up your Supabase dashboard. Go to the Table Editor. On the left, you will see that you can change the schema. If you change the schema to auth you will see a collection of tables, with users at the bottom. There isn’t much to see here since we don’t have a user yet.
The other two tables are in the public schema. Switch to the Database view and make sure you are in the public schema. This will show you the structure of our tables. The messages table is pretty straightforward, with fields for the id, timestamp, and body. Things get interesting with the user_id field which has a foreign key to the profiles table, which itself has a foreign key to auth.users.
The profiles table looks like it is just duplicating the auth.users table. Why are we bothering? For security purposes Supabase won’t allow us to expose the tables in auth to our clients. So, if we want to use any of the data in it (like the email addresses), we need to make our own copy. The profiles table is a pretty common solution and it provides a place for us to put other profile data like name, photos, etc…
Open up the migration file. You will find the schemas for the two tables including the syntax for establishing the foreign keys. At the bottom of the file, however, you will find the magic that populates the profile page. While we could handle this from the client code we can take a shortcut since we already have all of the information we need.
The first thing you will see is the function handle_new_user.
create function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, email)
values (new.id, new.email);
return new;
end;
$$ language plpgsql security definer;This is what is performing the insertion into the profiles table. The next thing you will see is the trigger:
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();This detects when a new user has been added to auth.users and then calls handle_new_user. Both of these live in the database itself, which gives them full power to access everything.
Row Level Security (RLS)
While we are in the migration file, let’s take a look at the RLS setup. RLS allows us to lock down our database tables so the general public can’t use our public API access and do whatever they want with our database. The first thing we want to do is enable it on our tables.
alter table "public"."messages" enable row level security;With no policies in place, the table is now inaccessible.
For our message board we would like everyone to be able to read the messages, so we can add a policy that provides SELECT access.
CREATE POLICY "Enable read access for all users" ON "public"."messages" FOR SELECT USING (true);On the other hand, we only want logged in users to be able to write messages. more critically, they should only be able to write messages that are associated with their user_id.
CREATE POLICY "Enable insert for users based on user_id" ON "public"."messages" FOR INSERT WITH CHECK (auth.uid() = user_id);The auth.uid() function is available in our policies to query the user data associated with the request. The user_id would be data that was part of the insertion.
The Supabase dashboard has a good collection of templates for creating policies. One approach is to go to the Authentication page in the dashboard and select “Policies”. From that page you can create new policies using their templates as starting places. Once you have worked out the policy, then you can transcribe them into a migration.
This would also be another valid use of AI assistants. They seem to be pretty good at identifying where you have forgotten things with your RLS policies. That said, I trust them when they say “this is missing”, but they are not validators and should not be your sole way to make sure all possibilities are covered.
Database functions
The last stop of the tour is src/lib/dbFunctions.ts. I am not going to dwell here, but I just want you to point out a few things about how we are getting the data out of the database. We have two functions fetchMessages and insertMessage. These do what you would expect. You can look at fetchMessages as another example of fetching joined data.
What I wanted you to notice in here is the typing. One of the things we can do is get Supabase to generate the types for our data stored in database tables.
To generate the types, we can run something like:
supabase gen types typescript --local --schema "public" > src/types/databaseTypes.ts
You can take a look in src/types/databaseTypes.ts to see what it output (though it is not the most readable document).
In src/lib/supabaseClient.ts I added
import type { Database } from "@/types/databaseTypes";and changed the call to createClient to createClient<Database>(SupabaseUrl, SupabaseAnonKey). This tells Typescript the return type of our basic selection queries.
We can also get types that correspond to particular tables like this:
import type { Tables } from "@/types/databaseTypes";
type MessageRow = Tables<"messages">;Unfortunately, this is less helpful for queries that involve multiple tables, so I had to do some type manipulation to get working types.
Okay – the tour is over.
Managing the user
We will start with the basic functionality of logging in and out.
Go to src/components/LoginStatus.tsx. We will start by putting all of the user logic in here.
Add two state variables in here: user and error.
Create a helper function called signIn.
const signIn = useCallback(async () => {
setError(null);
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
});
if (error) setError(error.message);
}, []);You will need to import supabase
import { supabase } from "@/lib/supabaseClient";The important part of this function is await supabase.auth.signInWithOAuth({ provider: "google"}).
The useCallback hook is another on of React’s built in hooks. It basically remembers the function definition so it doesn’t keep getting redefined every time the component reloads.
Now replace the return statement with this:
let item: React.ReactNode;
if (user) {
item = (
<div>
<p>Hello {user?.user_metadata?.name}</p>
</div>
);
} else {
item = <input type="button" value="Sign in with Google" onClick={signIn} />;
}
return (
<div>
{item}
{error && <p>{error}</p>}
</div>
);
You should now have a button on the page. When you click it, Google should try to log you in. However, we are not quite there yet.
We have logged in, but we don’t yet have the user data. We get the user data from the supabase client with supabase.auth.getUser(). The nice thing about this is that once we are logged in, everything can access the user using the supabase client. However, it is an asynchronous process, which is why we created the user state variable.
So, we are going to add a useEffect.
useEffect(() => {
const getUser = async () => {
const {
data: { user: newUser },
error,
} = await supabase.auth.getUser();
if (error) {
setError(error.message);
} else {
setUser(newUser);
}
};
getUser();
// Subscribe to auth state changes
const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
});
return () => {
sub.subscription.unsubscribe();
};
}, []);The getUser should be pretty easy to follow, we are just doing another async data access. The only extra parts there are so we can store and display the error instead of just dumping it to the console.
There is a new part, however. The supabase.auth.onAuthStateChange subscribes to changes in authentication, so if we lose our log in for any reason, this will drop the user object. As before, we are using the return statement to unsubscribe from this subscription.
Okay, now we should be able to log in and see the user name.
We should let the user log out as well.
Add another helper function:
const signOut = useCallback(async () => {
setError(null);
const { error } = await supabase.auth.signOut();
if (error) setError(error.message);
}, []);
Note that we don’t need to handle the user state variable because our subscription will detect the change for us.
Then add a log out button under the user name
<div>
<p>Hello {user?.user_metadata?.name}</p>
<input type="button" value="Sign out" onClick={signOut} />
</div>
Now you should be able to log in and out.
Now that we have this working, let’s wrap this in a little bit of abstraction so this component can concentrate on rendering.
Creating a hook
What we are going to do is to wrap the functionality in a custom hook called useUser.
Create a new file src/hooks/useUser.ts. Add a new function to the file:
export default function useUser() {
}
Move the two state variables, the useEffect, signOn and signOff into the hook. Then return all of the relevant pieces with
return { user, error, signIn, signOut };Replace all of the things you have removed with
const { user, error, signIn, signOut } = useUser();Make sure that everything still works.
The hook provides us some abstraction, making sure that LoginStatus really is a display component and all of the code required for managing the user is nicely tucked away in a different location. However, we are going to want to use the user object in other places in the code…
Contexts
This is a small app, but we need access to the user almost everywhere. As we have seen earlier, the solution is to lift the state up to the highest point in the hierarchy where everything that needs it is under the node holding the state. In our case, that would be the Home component in index.tsx, but App in _app.tsx would probably be appropriate if we ever wanted to add more pages. That is simple enough. Remove the const { user, error, signIn, signOut } = useUser(); from LoginStatus and put it in App. Now, to get these four things back to LoginStatus we need to add them to the props, passing them down the chain to LoginStatus. This approach leads to something called “props drilling”, where we are just passing these values along through the props of components that don’t even care about them. This is annoying and it adds to our technical debt as we now need to “touch” a number of unrelated components every time we want to make a change.
The alternative is something called a Context. We wrap the top level component with a context, which contains a value. Now, any component under that node, no matter how deep, can access that value without having it pass through the props of all of the intervening components (the truth is it actually does still pass down, it is just handled behind the scenes for us now).
We will create our context in useUser.ts1.
The first thing we need is a type for the hook.
export type UserHookType = {
user: User | null;
error: string | null;
signIn: () => void;
signOut: () => void;
};Now, we can create a typed hook with createContext
export const UserContext = createContext<UserHookType | undefined>(undefined);This says that the context can hold the data our useUser hook returns, or it could be undefined. We have also initialized it to undefined.
Import this into _app.tsx. We are now going to wrap the Component in the new context.
const userData = useUser();
return (
<UserContext.Provider value={userData}>
<Component {...pageProps} />
</UserContext.Provider>
);Notice how we are setting the value of the context to the structure returned by the useUser hook. Anything that falls in the hierarchy of any node within <UserContext.Provider value={userData}></UserContext.Provider> will be able to access the context using useContext(UserContext).
Another convention is to write another custom hook that wraps useContext to simplify the code that uses it. So, return to useUser.tsx and add this:
export function useUserContext() {
const context = useContext(UserContext);
if (!context) {
throw new Error(
"useUserContext must be used within a UserContext.Provider",
);
}
return context;
}The advantage to this pattern is that users of the context don’t need to know that they are using a context (though our name gives the game away here), they are just using a hook. Return to the LoginStatus component and replace useUser with useUserContext. Everything should still work.
Posting new notes
Now that we have our user, we are ready to add the functionality to our message board that allows logged in users to post messages.
newMessage component
We are going to start by looking at the NewMessage component in src/components/NewMessages.tsx. You will see that this looks like a simplified version of the editor we wrote for Simplepedia. This time we just have the one field. The missing functionality is in the postMessage function.
Here is what that function will look like:
const postMessage = async () => {
if (!user) {
setError("there is no logged in user");
} else {
const insertError = await insertMessage(user, message);
if (insertError) {
setError(insertError.message);
} else {
setMessage("");
setError("");
}
}
};The missing piece is the user. Now that we have the user information in the context, we can just break out useUserContext and add it to the component (make sure it is added to the top of the component, and not within postMessage). Note that since we only need user this time, you can use destructuring to just extract that value and ignore the other three.
Incorporating NewMessage
Now that we have the NewMessage, let’s add it to the interface in index.tsx. We want it to only be an option if the user is logged in. So, under the message list, add this:
{user?.email && (
<div>
<hr style={{ border: "1px solid lightgray", width: "80%" }} />
<NewMessage />
</div>
)}
Once again, we need that user object, so add it in with useUserContext.
You should now have a simple message board. Make a few posts and make sure it is working. Remember that there is a timer on how often the board updates so messages won’t appear instantly.
Finishing Up
- Make sure the tests are passing (with
pnpm test), there are no linting errors (withpnpm run lint), and no type errors (withpnpm run typecheck) - Add and commit your changes and push those commit(s) to GitHub.
- Submit your repository to Gradescope
Requirements
- There is a
useUserhook that manages theuserobject and logging on and off - There is a
UserContextthat has been applied to the top of the hierarchy - There is a
useUserContexthook that can be used to fetch any of the data returned by theuseUserhook LoginStatussupports logging in and out and displays the user’s nameNewMessageactually posts messages to the serverHomeconditionally shows thenewMessagecomponent- Pass all tests
- Pass all Biome checks
Recall that the Practical exercises are evaluated as “Satisfactory/Not yet satisfactory”. Your submission will need to implement all of the required functionality (i.e., pass all the tests) to be Satisfactory (2 points).
Footnotes
Convention is to put contexts in their own file in a contexts folder. For simplicity we are going to keep it in the same file as the hook.↩︎