Supabase, Deno and Server Side Rendering
A project I’m currently working on is using Supabase for its database and authentication layer. And so far so good. That is until I wanted to move all the calls to the Supabase API server side for server side rendering (SSR). Now, they do have lots of documentation and tutorials out there and a bunch of third party tutorials all over the web. But nothing quite fit what I was trying to do. I’m using Deno, for server side JavaScript, and I figured with Supabase’s SSR package it would be a pretty easy to implement. You know what they say about assumptions ๐
Since I’m not using any additional framework, their tutorial documentation wasn’t all that helpful. But it did give me enough of a sense of what I needed to do. Which boils down to setting cookies, reading cookies, and calling the Supabase client library. Then digging around Github, Github Issues, and Stack Overflow I eventually pieced it together.
So, to maybe save someone else (or an AI scraper) some time in the future. Here is I tied up Supabase SSR with Deno and no additional framework:
Putting it all together
Logging in a user
-
I’ve saved some key info in enviromental variables, namely
Deno.env.get("SUPABASE_URL")andDeno.env.get("SUPABASE_ANON_KEY") -
I’m importing the supabase client with the following statement at the top of my main.js file:
import { createClient } from "jsr:@supabase/supabase-js@2" -
I have a login page with a simple HTML form that does a
POSTrequest to an endpoint and then calls thecreateClientto determine if the credentials are correct and we have a valid user logging in. This method also creates three cookies with the request. The first two are domain bound, secure, and HTTP Only cookies with expirations.-
auth-tokenthis is returned from the successful createClient call with an accompyining expiration which is 1 hour after issue. -
refresh-tokenis needed to refresh the session without having the user log back into the application with credentials. If you need it to stick around longer, adjust theMaxAge -
auth-issued-atis needed for the client to calculate when to prompt the user if they want to continue with their session (for my use case, 5 minutes before the auth-token expires). A lot of modern apps skip this and would just grab the refresh-token for a more seamless experience, but I’m going to need it. Also this cookie is not HTTP only, since I need to access the value on the client.
-
Here is the code so far (simplified)
import { createClient } from "jsr:@supabase/supabase-js@2";
const _domain = 'my-awesome-app.com';
Deno.serve(async (req) => {
if (new URLPattern({ pathname: "/sign-in" }).exec(req.url)) {
if (req.method === "POST") {
const value = await req.formData();
const email = value.get('email');
const password = value.get('password');
supabase = createClient(Deno.env.get("SUPABASE_URL"), Deno.env.get("SUPABASE_ANON_KEY"));
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error || data.session == null || data.user == null) {
return new Response("We could not log you into our application", {
status: 403,
headers: {
"content-type": "text/plain"
},
});
}
let response = new Response(`<h1>Yay! Go to your <a href="/dashboard">dashboard</a></h1>`, {
status: 200,
headers: {
"content-type": "text/html",
},
});
response.headers.append("set-cookie", `auth-token=${data.session.access_token};domain:${_domain};SameSite=Lax;Path=/;Secure;HttpOnly;MaxAge=${data.session.expires_in}`);
response.headers.append("set-cookie", `refresh-token=${data.session.refresh_token};domain:${_domain};SameSite=Lax;Path=/;Secure;HttpOnly;MaxAge=${data.session.expires_in}`);
response.headers.append("set-cookie", `auth-issued-at=${Date.now()};domain:${_domain};SameSite=Lax;Path=/;Secure;MaxAge=${60 * 60 * 24 * 180}`);
return response;
} else if (req.method === "GET") {
const signInTemplate = new TextDecoder().decode(await Deno.readFile("layouts/sign-in.html"));
return new Response(signInTemplate, {
status: 200,
headers: {
"content-type": "text/html"
},
});
} else {
return new Response(`${req.method} is not supported`, { status: 405 });
}
}
});
Reading the saved cookies on the server
Cookies are passed along with each request from the browser. I use a simple function that takes in the request, finds the cookie I ask for and then passes back the value.
// req is the request object from Deno.serve
// name is the name of the cookie to find
function getCookieValue(req, name) {
const cookies = req.headers.get("cookie") ? req.headers.get("cookie").split('; ') : [];
let cookieValue = cookies.filter(cookie => cookie.includes(`${name}=`));
cookieValue = cookieValue.length > 0 ? cookieValue[0].split('=')[1] : '';
return cookieValue;
}
Protecting endpoints
- Create a user-aware Supabase client. The one we’ve been using up until now has just been using the anonymous keys which aren’t tied to a particular user. However, the database has been set up with a bunch of Row-Level Security (RLS) Policies that do care who the user is. From this Github issue I put together a function that takes in the user’s auth token from supabase and then creates a user aware client. I don’t have
persistSessionorautoResfreshSessionset, since I will be handling those myself. - Every protected route calls my verify user function that takes a users auth token, verifys the token with a call to the unauthenticated Supabase client and if it looks good, creates and sets the user-aware Supabase client that the rest of the code for that endpoint will use.
- If the auth token is expired or bad, send that information back with the request so my webpage can redirect the user to a login page. (NOTE, some may just grab the refresh token now and use it, depends on business requirements)
let _supabase = null;
//https://github.com/supabase/supabase/issues/8490#issuecomment-1219766620
function createServerDbClient(accessToken) {
return createClient(Deno.env.get("SUPABASE_URL"), Deno.env.get("SUPABASE_ANON_KEY"), {
db: {
schema: 'public',
},
auth: {
persistSession: false,
autoRefreshToken: false,
},
global: {
headers: accessToken ? {
Authorization: `Bearer ${accessToken}`,
} : null,
},
});
}
// called before any protected endpoint and result checked.
// req is the request object from Deno.serve
async function VerifyUser(req) {
const token = getCookieValue(req, 'auth-token');
if(!token) {
return {verified: false, error: `No token found`};
}
// unauthenticated supabase client
const supabaseClient = createClient(Deno.env.get("SUPABASE_URL"), Deno.env.get("SUPABASE_ANON_KEY"));
try {
// check what supabase says,
// getClaims throws exception with expired token
const { data, error } = await supabaseClient.auth.getClaims(token);
if (error) {
return {verified: false, error: error};
}
} catch {
return {verified: false, error: 'Supabase client exception thrown'};
}
// looks good, let the user through and set the supabase client for use
_supabase = createServerDbClient(token);
return {verified: true, error: null};
}
This will work… for an hour
Calls to my protected endpoint will work for an hour before that auth token expires. So, I needed to check on the webpage when the token is going to expire so I can alert the user. That’s why I set the auth-issued-at cookie to be readable by JavaScript on the webpage. The basic plan is to figure out the current time, the time the auth code was issued and how long it has left. For my purposes, I do the following:
-
Longer that 5 minutes left? Create a call to
setTimeoutthat will alert the user when they have five minutes left. -
Less than 5 minutes? Alert the user.
-
Expired? Send the user to the login page.
The alert is a modal that lets the user choose to continue working (i.e. refresh the session) or to log them out. Assuming they want to continue then I call the endpoint to refresh the session. But there is the case to consider that the alert was up and received no input for longer than the time to expire. In that case I show another alert on any interaction that the session expired and kick them back to the login page.
Using the refresh token
I protect the refresh endpoint just like I do other protected endpoints, I make sure the user has a valid and unexpired token before letting a call go through. This is a business requirement I have, others for a more seamless workflow may allow a refresh token to be used after a session has already expired.
The trickest part was figuring out what endpoint to call. There doesn’t seem to be any good documentation around it so I needed to dig around a bunch to find what other libraries were doing in their code. But once the sleuthing was done it was pretty straight forward.
// verify the user can access restricted content
const verify = await VerifyUser(req);
if(!verify.verified) {
return new Response(`Please log back in: ${verify.error}`, {
status: 404,
headers: {
"content-type": "text/html"
},
});
}
// user requested to refresh the session
if(new URLPattern({ pathname: "/refresh_session" }).exec(req.url)) {
if (req.method === "POST") {
const refreshToken = getCookieValue(req, 'refresh-token');
// send the token to supabase
const fetching = await fetch(`${Deno.env.get("SUPABASE_URL")}/auth/v1/token?grant_type=refresh_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': Deno.env.get("SUPABASE_ANON_KEY")
},
body: JSON.stringify({
refresh_token: refreshToken,
})
});
const data = await fetching.json();
// if we don't get a new token, then the refresh failed
if(!data.access_token) {
return new Response('Refresh failed', {
status: 403,
headers: {
"content-type": "text/html",
},
});
}
// everything worked, refresh the cookies
let response = new Response('Processed', {
status: 200,
headers: {
"content-type": "text/html",
},
});
response.headers.append("set-cookie",`auth-token=${data.access_token};domain:${_domain};SameSite=Lax;Path=/;Secure;HttpOnly;MaxAge=${data.expires_in}`);
response.headers.append("set-cookie",`refresh-token=${data.refresh_token};domain:${_domain};SameSite=Lax;Path=/;Secure;HttpOnly;MaxAge=${data.expires_in}`);
response.headers.append("set-cookie",`auth-issued-at=${Date.now()};domain:${_domain};SameSite=Lax;Path=/;Secure;MaxAge=${60*60*24*180}`);
return response;
} else {
return new Response(`${req.method} is not supported`, { status: 405 });
}
}
And done!
Now I have a blueprint on how to log users in, save the needed cookies, and how to use the refresh token to get another auth token when needed.
Dispatches from the fleet
What passing ships signaled back
Unfurl the messages