Simple auth with Deno's Fresh + Supabase

Read Time:19 Minute, 57 Second

Contents

  1. Getting Started
  2. Set up a theme and some components
  3. Pseudo-authentication with cookies
  4. Actual authentication with Supabase
  5. Add auth middleware and protected route
  6. Persist sessions with Redis
  7. Wrap up

Intro

For me, the one thing that comes up almost immediately for anything I want to build on the web is authentication. Maybe it’s my background in building enterprise-grade web apps but I just can’t imagine any actually useful app that would not involve some kind of functionality that is only available to signed in users. And since I wanted to try out Deno’s Fresh framework for the longest time, I thought I’d combine it with a simple backend built with Supabase to create a small app with a simple cookie-based authentication scheme.

With this article, I wanted combine my irrational lust for trying out hot new fresh technologies (seriously, have you seen that logo?) with an at least somewhat level-headed evaluation of its suitability to be used for a real-world, production use case.

That evaluation mainly consists of two things for me: How does implementation of a non-trivial functionality like authentication look like? Is the framework suited to setting up a well organised code base, does it make it easy to do the simple stuff but provide enough flexibility to accommodate more complex business logic?

So, if you want to join me on that endeavour, let’s get started!

Oh, and by the way, here’s the project’s code and here you can try it out in action!

Getting Started

Let’s set up our new app by running the following commands:

Enter fullscreen mode Exit fullscreen mode

Go ahead and confirm setting up the Tailwind integration, you probably also want the VSCode integration although Helix is obviously the only editor that will earn you a star in my book.

There are some things that we don’t need from what’s been generated, so let’s remove them:

Enter fullscreen mode Exit fullscreen mode

Let’s add all the dependencies that we’re going to need right away. Add this to your import_map.json:

Enter fullscreen mode Exit fullscreen mode

The details matter here. Note that the supabase import does not have a / at the end. Also, it’s an esm import because there is no official supabase package on Deno.land at the time of writing. Using the esm import is actually their recommended way of using Supabase from Deno right now, as per their issue tracker.

Let’s then clean up routes/index.tsx and remove the components we’re not using anymore. The file should now looks like this:

Enter fullscreen mode Exit fullscreen mode

Marketing really outdid themselves this time coming up with the product name!

Now, I don’t know about you but for me a dev server belongs on port 8080, everything else is just ludicrous. That’s why I added this to my main.ts:

Enter fullscreen mode Exit fullscreen mode

Ok, time to run the app for the first time!

Enter fullscreen mode Exit fullscreen mode

Dripping.

Set up a theme and some components

Now, before we go any further, let’s quickly add some styling and some components to have it out of the way.

The design department has still not delivered our new corporate design so I guess we’ll have to do it ourselves. I know styling is a very subjective thing so go ahead and fiddle around with the theme to your heart’s content, I’ll wait here until you’re back in two days, when you finally get out of that rabbit hole (Been there, done that so many times…).

Here’s what I did to my twind.config.js:

Enter fullscreen mode Exit fullscreen mode

Have a look at the Tailwind or Twind docs for all the options.

I also like to build out even small projects in a way that it is at least halfway realistic and could scale up and grow into an actual app. For this, we should definitely create some building blocks to build our app with. Let’s get that out of the way so we can fully focus on building the functionality laterj on.

The Fresh docs provide a small collection of components that provide a great starting point for our use case. You can have a look at this article’s repo to see which components I created. Copy the components folder into your project or create your own.

Now we update routes/index.js again to get rid of some of the clutter:

Enter fullscreen mode Exit fullscreen mode

It’s also a good idea to add the following entries to the import_map.json for convenience and cleanliness:

Enter fullscreen mode Exit fullscreen mode

Let’s make sure everything is up and running…

Enter fullscreen mode Exit fullscreen mode

Neat!

Pseudo-authentication with cookies

Alright, let’s get going on some auth!

This section will closely follow this article from the Deno blog, have a look if you’re interested. We will be introducing some slight differences, though, so it’s probably best to just follow along here.

Add a SignInForm island

Well, we need a way to actually sign in to our little app, don’t we? Let’s add a SignInForm component in the islands folder.

Here’s an interesting tid bit: Some of our components have an IS_BROWSER check in them to determine if they’re disabled or not. If you add the SignInForm as a “dry” component, this won’t work with the server side rendering of the form. I assume that there must at least be one island that is being delivered for Fresh to include its runtime into the bundle where IS_BROWSER is being imported from. If you know any details on this, let me know!

Enter fullscreen mode Exit fullscreen mode

Update the home page

Let’s update our home page with the form and a paragraph that’s telling us the current auth status of the user.

Enter fullscreen mode Exit fullscreen mode

There are a few interesting things happening here. In Fresh, a route actually consists of two parts: a page component and a handler, which we are introducing here. Handlers are called each time a request is being made to a particular route. It receives the Request object and must return a Response, where calling ctx.render() actually returns a response under the hood.

By the way, this is an example of file-based routing, which is all the rage right now in the industry. So, yay us for hopping aboard the hype train!

So, as you can see, we respond to any GET request by rendering our index page, injecting an isAllowed prop along the way. And yes, all we’re doing right now is looking for the magic string "superzitrone" in an auth cookie. Come to think of it, that may have been an awesome name for this project…

Anyways, we should now have a pretty looking sign-in form on the home page when we’re not signed in – but it doesn’t do anything yet.

Set up a sign-in route

Let’s add a sign-in route to routes/api. This folder is another example of file-based routing and will get special treatment by Fresh. As you may have guessed, anything in here will be mounted as an api endpoint and can be called to provide any non-rendering functionality that we might need, acting as a sort of API backend to our app.

Enter fullscreen mode Exit fullscreen mode

We extract the email and username from the form data and, well, check for them being set to some specific values for now. If so, we return a Response with our special auth and a location header set. Setting the location header to "/" acts as a redirect to our home page (the status code must be in the 3xx range for this), where the route handler we just set up will then pick up the auth cookie.

Set up a sign-out route

Next, we need a sign-out route. We simply use the handy deleteCookie from Deno’s std to delete the auth cookie and redirect to home:

Enter fullscreen mode Exit fullscreen mode

Great success!

Actual authentication with Supabase

Ok, now finally on to the fun part! We have a barebones flow in place, let’s actually add Supabase to the mix. If you haven’t already, go over to Supabase and create an account and a new project (it’s super simple and free). In fact, I recommend you check out their blog some time, you can witness some really great technology in the making there. Once you’ve created a project, navigate to the SQL-Editor tab and you’ll find a User Management Starter that you can use to get set up with everything we need.

Preparations

To start using Supabase in our app, let’s set it up in a dedicated lib file supabase.ts:

Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode Exit fullscreen mode

We want to read .env files with the dotenv package, which also gives us the config function to make sure that all required environment variables are set. Have a look at the documentation for the details. We are going to add two .env files:

Enter fullscreen mode Exit fullscreen mode

The .env.example file let’s us define which environment variables our app needs to function and should be checked in to source control. The .env file on the other hand should not be checked in and should contain the actual keys from your personal Supabase project. Here’s .env.example:

Enter fullscreen mode Exit fullscreen mode

Add a sign-up route

Before we can actually authenticate any users with Supabase, we need to have a way to create them in the first place, d’uh! We’ll keep it simple and create authentication based on e-mail address and password, none of that fancy sell-my-data social auth kinda shenanigans.

Let’s be the responsible devs our CTO wants us to be and do this as DRY as possible. Since we basically need the same fields for sign-up and sign-in, we’ll reuse the SignInForm we created and make it call a different url based on where it is being rendered. My OCD is also making me rename the thing to something more generic:

Enter fullscreen mode Exit fullscreen mode

Make it look like so:

Enter fullscreen mode Exit fullscreen mode

Ah, that’s better. Time to also move the form from the home page to a dedicated sign-in and sign-up page:

Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode Exit fullscreen mode

I’m sure you can figure out how routes/sign-up.tsx looks like. Here’s the updated home page, now with a link instead of the form:

Enter fullscreen mode Exit fullscreen mode

Don’t forget to uncomment the sign-up link in components/Layout.tsx that I already sneakily put in there so we can actually reach our new route!

Alright. At this point, the authentication should still be working as before, but embedded in a moar better UI.

Implementation

Ok, now, finally. Some actual Supabase authentication. Let’s do everything in order and start by implementing our sign-up flow. Add a new api endpoint to our app:

Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode Exit fullscreen mode

As you can see there’s a few things left to get this up to production standards but we’re not going to bother with that right now.

Works on my machineβ„’. On to the sign-in route to make sure it does on yours too! sign-in.ts becomes this:

Enter fullscreen mode Exit fullscreen mode

At this point, it should be possible for you to create a new account, confirm the e-mail address and sign in to our little app. Cool beans!

Except, I tricked you a bit there. We are still setting the same old cookie with the magic string that our frontend is looking for. Sure, we only allow that to happen if the sign in with Supabase is successful but what we should really do here is setting the actual access token that Supabase returns into the cookie and check for that.

This, by the way, already hints at the real juicy part of this whole project – what are we going to do once we set an actual access token in the cookie? What are we going to compare that against? How would a page handler know what constitutes a valid session token? After all, there could be any string in there. Let’s chew on that one for a bit, we’ll tie it all together in the last chapter!

For now, let’s move on to another intermediary step if you will and add a middleware that will take care of authentication for all our routes. This way, we won’t have to duplicate that logic in all our page handlers but centralise it in a single place. Then, we can make it actually check for the real thing.

Add auth middleware and protected route

To protect a route, we’ll use a middleware to do this properly, as explained above. A _middleware.ts file can be defined anywhere inside the routes folder which can be used to add custom logic that will be run before or after each request to that route. By adding it to the top-level routes folder, it will be run for any request to our app. Let’s add the following for now:

Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode Exit fullscreen mode

For every request, we check if the user is trying to access our protected route. If so, we are trying to parse the auth cookie, and if we find it, we inject that user’s data into the request which can then be picked up by our individual page handlers. Interesting concept those middlewares, huh? Do know how one would write a middleware that intercepted responses instead of requests? Hint: That ctx.next() can be used similar to how you write recursive calls, so to speak. Powerful stuff.

Moving on! As you can see, we created a new ServerState type here. With this, we can define a “standard” page props type (PageProps<ServerState>) for all our page handlers to consume, let’s create a secret page and use it there first:

Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode Exit fullscreen mode

See what we’re doing in the custom handler there? The ServerState we defined is now part of the ctx which we pass on to ctx.render() to inject it into the component. In there, we now have access to the server state and can use it to build out our business logic.

Try it out in the browser and you should now be able to sign in and out of the app and should only be able to reach the secret page if you’re signed in, yeeha!

Clean up

Something’s still off, though. We still have some custom handlers in place that are checking for the magic string cookie which became rather pointless since we have that actual protection mechanism at the route level now. We can basically standardise all our page handlers to simply pass on the server state into the page. And whilst we’re on it, let’s get even more DRY and move the isAllowed logic to Layout so we don’t have to repeat it in every page we’re creating.

Step by step. Here’s the updated Layout component:

Enter fullscreen mode Exit fullscreen mode

With this, we can now standardise all our pages, namely routes/index.tsx, routes/sign-in.tsx, and routes/sign-up.tsx, here’s secret.tsx as an example:

Enter fullscreen mode Exit fullscreen mode

Repeat the above for all mentioned pages.

Complete the circle

One last thing to do. In routes/api/sign-in.ts, line 29-30, we can now actually set the access token we get from Supabase instead of our magic string:

Enter fullscreen mode Exit fullscreen mode

Now, we have completely removed the pseudo-authentication that relied on our magic little string and upgraded our system to use an actual auth provider. Remember what I said about statefulness when we first implemented the sign-in with Supabase, though? Sure, we are now working with an actual access token and we are preventing unauthorised access in a robust centralised manner, but with all that in place, it still doesn’t really matter what is in that cookie. You could set it to an arbitrary string and get access to our site. Try it out in your browser’s developer tools to see for yourself. Change the value of the cookie to anything you want and navigate to the secret page. Heck, you could even create one from scratch, all you need to know is that the name is supposed to be “auth” – that’s it. Fun times!

Persist sessions with Redis

Ok, this is obviously not going to withstand the scrutiny of our Chief Security Officer (you do still have one of those at your company, don’t you?).

So what do we actually want to do exactly? As we said before, we need a way to compare the access token that a user passes to us in their cookie on each request to something that tells us that that token is actually legit. We know (or, assume) that what we are being handed from Supabase is legit but after we put it into the token, our app sort of loses knowledge of that.

What would be great is if there was a way to listen for any call to Supabase that hands out a token, cache that token and maybe some associated user data somewhere in a sort of session store and then, for every call to a protected route, have our middleware check in that session store if what it received is actually known to our app. This way, we would make our “backend” stateful in the way that it could remember who was signed in.

Enter, you guessed it, Redis, whose tagline literally is “an in-memory data structure store, used as a database, or cache”. Boom, right on target.

We just have to figure out at which exact points in our authentication flow we want to loop Redis into the mix. If only we had a surefire way of catching all the relevant auth events and update our session store accordingly… I know, it’s not even funny anymore: Obviously, the Supabase SDK provides exactly this, their recommended way of doing this sort of stuff. As if they knew what they were doing!

Ok, easy peasy. Let’s add Redis to the mix. Just like with Supabase, let’s create a lib that takes care of initialising an instance of it. For this to work, you need to have Redis installed on your system, obviously. Afterwards, go ahead and create the following:

Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode Exit fullscreen mode

Of course this is no real way to do this, as we are hard-coding our app to only work in a local dev environment but this should suffice for now. Making all of this deployable is a whole different can of worms anyway that we don’t need to concern ourselves with right now.

Next, let’s use that super useful onAuthStateChange function from the Supabase SDK. I like to keep our concerns neatly separated so I will put this into our main.ts instead of our supabase lib as to not couple the initialisation of our libraries too tightly together.

Enter fullscreen mode Exit fullscreen mode

We are listening for both the SIGNED_IN and TOKEN_REFRESHED events that are being emitted by Supabase. What’s neat about this is that we don’t have to “manually” concern ourselves with this in our sign-in API handler, and are free to change that around if we wanted to. Again, a nice separation of concerns.

Ok, on to our middleware. Remember that comment I made when we first created the middleware? That is exactly where we can now look up the actual access token that is being passed in, and, if we know it, inject some user data into the context (see line 31):